Skip to main content
Java · lesson 19 of 66
Open the Java page →
Java intermediate Lesson 19 of 66

Interfaces in Java — A Deep Dive

Go beyond the basics — learn interface segregation, default methods, functional interfaces, and how interfaces enable real-world design patterns.

By SkillByExample · Updated

An interface is a contract: the methods a type promises, with no instance state. At the intermediate level the question shifts from “how do I implement one?” to “how should I design one?” — how big, how layered, how open. Each example is a single file run with the JDK 21 source launcher (java File.java).

What an interface can contain

Abstract methods, default methods with bodies, static helpers, private helpers shared by defaults (Java 9+), and constants.

public class Anatomy {
    public static void main(String[] args) {
        PaymentGateway gw = new FakeGateway();
        System.out.println(gw.charge("cus_1", 19.99));
        System.out.println(gw.chargeWithRetry("cus_2", 5.00));
        System.out.println(PaymentGateway.format(19.99));
        System.out.println("retries allowed: " + PaymentGateway.MAX_RETRIES);
    }
}

interface PaymentGateway {
    int MAX_RETRIES = 3;                                  // implicitly public static final

    String charge(String customer, double amount);        // abstract

    default String chargeWithRetry(String customer, double amount) {
        return log("retrying up to " + MAX_RETRIES) + charge(customer, amount);
    }

    static String format(double amount) { return String.format("$%.2f", amount); }

    private String log(String msg) { return "[" + msg + "] "; }   // helper for defaults only
}

class FakeGateway implements PaymentGateway {
    public String charge(String customer, double amount) {
        return "charged " + customer + " " + PaymentGateway.format(amount);
    }
}
$ java Anatomy.java
charged cus_1 $19.99
[retrying up to 3] charged cus_2 $5.00
$19.99
retries allowed: 3

FakeGateway wrote one method and got chargeWithRetry for free. format is called through the interface name, never through an instance, and log is invisible outside the interface.

Interface segregation: fat interfaces force fake methods

One large interface forces every implementor to provide methods it has no use for.

public class FatInterface {
    public static void main(String[] args) {
        UserService svc = new InMemoryUsers();
        svc.save("alice");
        System.out.println(svc.find("alice"));
        svc.sendWelcomeEmail("alice");
    }
}

interface UserService {
    void save(String user);
    String find(String user);
    void sendWelcomeEmail(String user);
    byte[] exportCsv();
}

class InMemoryUsers implements UserService {
    private final java.util.Set<String> users = new java.util.HashSet<>();
    public void save(String u)  { users.add(u); }
    public String find(String u) { return users.contains(u) ? "found " + u : "no " + u; }
    public void sendWelcomeEmail(String u) { throw new UnsupportedOperationException("no email here"); }
    public byte[] exportCsv()   { throw new UnsupportedOperationException("no export here"); }
}
$ java FatInterface.java
found alice
Exception in thread "main" java.lang.UnsupportedOperationException: no email here
	at InMemoryUsers.sendWelcomeEmail(FatInterface.java:21)
	at FatInterface.main(FatInterface.java:6)

The type said InMemoryUsers could send email; it couldn’t, and the lie surfaced only at runtime. Splitting the interface makes the capability visible in the type:

import java.util.*;

public class Segregated {
    public static void main(String[] args) {
        InMemoryUsers repo = new InMemoryUsers();
        UserNotifier notifier = user -> System.out.println("welcome mail to " + user);
        register("alice", repo, notifier);
        System.out.println(repo.find("alice"));
    }

    static void register(String user, UserRepository repo, UserNotifier notifier) {
        repo.save(user);
        notifier.sendWelcome(user);
    }
}

interface UserRepository { void save(String user); String find(String user); }
interface UserNotifier   { void sendWelcome(String user); }

class InMemoryUsers implements UserRepository {
    private final Set<String> users = new HashSet<>();
    public void save(String u)   { users.add(u); }
    public String find(String u) { return users.contains(u) ? "found " + u : "no " + u; }
}
$ java Segregated.java
welcome mail to alice
found alice

register asks for exactly the two capabilities it uses. UserNotifier has one method, so a lambda implements it, and passing an InMemoryUsers as a notifier is now a compile error rather than a runtime surprise.

Interfaces extending interfaces

An interface can extend several others and add defaults built on their methods.

import java.util.*;

public class Layered {
    public static void main(String[] args) {
        MemoryFs fs = new MemoryFs();
        fs.write("/a.txt", "hello");
        fs.copy("/a.txt", "/b.txt");
        Readable reader = fs;                    // narrow view: read-only
        System.out.println(reader.read("/b.txt"));
        System.out.println(fs instanceof Writable);
    }
}

interface Readable { String read(String path); }
interface Writable { void write(String path, String content); }

interface ReadWritable extends Readable, Writable {
    default void copy(String from, String to) { write(to, read(from)); }
}

class MemoryFs implements ReadWritable {
    private final Map<String, String> files = new HashMap<>();
    public String read(String p)             { return files.get(p); }
    public void write(String p, String text) { files.put(p, text); }
}
$ java Layered.java
hello
true

A MemoryFs is a ReadWritable, a Readable and a Writable at once. Handing out the Readable view lets a caller read without being able to write through that reference.

Functional interfaces as pipeline steps

With exactly one abstract method, an interface can be implemented by a lambda or method reference, and default methods can compose those lambdas.

public class Pipeline {
    @FunctionalInterface
    interface Transformer<T, R> {
        R transform(T input);
        default <V> Transformer<T, V> andThen(Transformer<R, V> next) {
            return input -> next.transform(transform(input));
        }
    }

    public static void main(String[] args) {
        Transformer<String, String> trim = String::trim;
        Transformer<String, String> lower = String::toLowerCase;
        Transformer<String, Integer> words = s -> s.split("\\s+").length;
        Transformer<String, Integer> pipeline = trim.andThen(lower).andThen(words);
        System.out.println(pipeline.transform("  The Quick  BROWN fox "));
        System.out.println(trim.andThen(lower).transform("  MiXeD  "));
    }
}
$ java Pipeline.java
4
mixed

andThen returns a new lambda wrapping two others, so the pipeline is nested calls, not a list of steps. The JDK’s java.util.function.Function has the same andThen, plus compose for the other direction.

Strategy: swap implementations behind an interface

import java.util.*;

public class Pricing {
    interface Discount { double apply(double price); default String label() { return getClass().getSimpleName(); } }
    static class NoDiscount implements Discount { public double apply(double p) { return p; } }
    static class Percent implements Discount {
        private final double pct;
        Percent(double pct) { this.pct = pct; }
        public double apply(double p) { return p * (1 - pct / 100); }
        public String label() { return pct + "% off"; }
    }

    public static void main(String[] args) {
        List<Discount> strategies = List.of(new NoDiscount(), new Percent(15), p -> Math.max(0, p - 30));
        for (Discount d : strategies) {
            System.out.printf("%-12s %.2f%n", d.label().startsWith("Pricing$$Lambda") ? "flat -30" : d.label(), d.apply(120));
        }
    }
}
$ java Pricing.java
NoDiscount   120.00
15.0% off    102.00
flat -30     90.00

The loop calls the same two methods on every strategy. The third one is a lambda: Discount has one abstract method, and its default label() reports the lambda’s generated class name, which starts with Pricing$$Lambda — so the loop prints a friendlier label for it.

Sealed interfaces (Java 17)

sealed ... permits fixes the set of implementations. A switch over the interface can then be exhaustive without a default.

public class Shapes {
    sealed interface Shape permits Circle, Square, Triangle {}
    record Circle(double r) implements Shape {}
    record Square(double side) implements Shape {}
    record Triangle(double base, double height) implements Shape {}

    static double area(Shape s) {
        return switch (s) {
            case Circle c   -> Math.PI * c.r() * c.r();
            case Square sq  -> sq.side() * sq.side();
            case Triangle t -> 0.5 * t.base() * t.height();
        };
    }

    public static void main(String[] args) {
        for (Shape s : new Shape[] {new Circle(1), new Square(2), new Triangle(3, 4)}) {
            System.out.printf("%s -> %.2f%n", s, area(s));
        }
    }
}
$ java Shapes.java
Circle[r=1.0] -> 3.14
Square[side=2.0] -> 4.00
Triangle[base=3.0, height=4.0] -> 6.00

Add a fourth record to permits without a matching case and area stops compiling. An ordinary interface can’t offer that: anyone could implement it anywhere.

Test doubles through interfaces

Code that depends on an interface can be tested with a fake that records calls instead of sending real email.

import java.util.*;

public class Registration {
    interface EmailService { void send(String to, String subject); }

    static class RecordingEmail implements EmailService {
        final List<String> sent = new ArrayList<>();
        public void send(String to, String subject) { sent.add(to + ": " + subject); }
    }

    static class UserRegistration {
        private final EmailService email;
        UserRegistration(EmailService email) { this.email = email; }
        void register(String address) {
            if (!address.contains("@")) throw new IllegalArgumentException("bad address " + address);
            email.send(address, "Welcome!");
        }
    }

    public static void main(String[] args) {
        RecordingEmail fake = new RecordingEmail();
        UserRegistration reg = new UserRegistration(fake);
        reg.register("alice@example.com");
        try { reg.register("bob"); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
        System.out.println("emails sent: " + fake.sent);
        System.out.println(fake.sent.size() == 1 ? "TEST PASSED" : "TEST FAILED");
    }
}
$ java Registration.java
rejected: bad address bob
emails sent: [alice@example.com: Welcome!]
TEST PASSED

UserRegistration never knows it was handed a fake. The same class runs in production with an SMTP implementation passed to its constructor; nothing in it changes.

Practice

  1. Define a SortStrategy interface and an insertion-sort implementation, use it through a helper that sorts a copy of a list, and check it agrees with Collections.sort.
Solution
import java.util.*;

public class Sorting {
    interface SortStrategy { void sort(List<Integer> list); }

    static class InsertionSort implements SortStrategy {
        public void sort(List<Integer> a) {
            for (int i = 1; i < a.size(); i++) {
                int key = a.get(i), j = i - 1;
                while (j >= 0 && a.get(j) > key) { a.set(j + 1, a.get(j)); j--; }
                a.set(j + 1, key);
            }
        }
    }

    static List<Integer> sorted(List<Integer> data, SortStrategy s) {
        List<Integer> copy = new ArrayList<>(data);
        s.sort(copy);
        return copy;
    }

    public static void main(String[] args) {
        List<Integer> data = List.of(5, 2, 8, 1, 9, 3);
        List<Integer> mine = sorted(data, new InsertionSort());
        List<Integer> jdk = sorted(data, Collections::sort);
        System.out.println(mine + " agrees: " + mine.equals(jdk));
    }
}
$ java Sorting.java
[1, 2, 3, 5, 8, 9] agrees: true

Collections::sort fits SortStrategy because it takes a List and returns nothing — a method reference can stand in for a whole strategy class.

  1. Add a compose(Transformer<V, T> before) default to Transformer that runs before first, and show lower.compose(trim) gives the same result as trim.andThen(lower).
Solution
public class Compose {
    @FunctionalInterface
    interface Transformer<T, R> {
        R transform(T input);
        default <V> Transformer<T, V> andThen(Transformer<R, V> next) { return in -> next.transform(transform(in)); }
        default <V> Transformer<V, R> compose(Transformer<V, T> before) { return in -> transform(before.transform(in)); }
    }

    public static void main(String[] args) {
        Transformer<String, String> trim = String::trim;
        Transformer<String, String> lower = String::toLowerCase;
        System.out.println("[" + lower.compose(trim).transform("  HeLLo ") + "]");
        System.out.println("[" + trim.andThen(lower).transform("  HeLLo ") + "]");
    }
}
$ java Compose.java
[hello]
[hello]

a.compose(b) means “b, then a”; b.andThen(a) means the same thing written in reading order.

  1. A gateway once offered fullRefund(id) implemented as refund(id, -1), using -1 to mean “everything”. Replace that sentinel with two methods: refund(id, amount) and a default refundAll(id) built on an abstract amountCharged(id).
Solution
import java.util.HashMap;
import java.util.Map;

public class Refunds {
    interface Gateway {
        double amountCharged(String id);
        String refund(String id, double amount);
        default String refundAll(String id) { return refund(id, amountCharged(id)); }
    }

    public static void main(String[] args) {
        Map<String, Double> charges = new HashMap<>(Map.of("ch_1", 40.0));
        Gateway gw = new Gateway() {
            public double amountCharged(String id) { return charges.get(id); }
            public String refund(String id, double amount) { return "refunded " + amount + " on " + id; }
        };
        System.out.println(gw.refund("ch_1", 10));
        System.out.println(gw.refundAll("ch_1"));
    }
}
$ java Refunds.java
refunded 10.0 on ch_1
refunded 40.0 on ch_1

Each method now means one thing, and no implementation has to remember that -1 is special. A real refund could also reject negative amounts outright.

  1. Add a Rect record to the sealed Shape hierarchy but not to the switch. What does the compiler report?
Solution
public class SealedMissing {
    sealed interface Shape permits Circle, Square, Rect {}
    record Circle(double r) implements Shape {}
    record Square(double side) implements Shape {}
    record Rect(double w, double h) implements Shape {}

    static double area(Shape s) {
        return switch (s) {
            case Circle c  -> Math.PI * c.r() * c.r();
            case Square sq -> sq.side() * sq.side();
        };
    }

    public static void main(String[] args) { System.out.println(area(new Rect(2, 3))); }
}
$ java SealedMissing.java
SealedMissing.java:8: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^
1 error
error: compilation failed

The compiler knows every permitted subtype, so a missing case is caught at build time rather than falling into a default branch at runtime.

Frequently Asked Questions

Can an interface extend another interface?
Yes. An interface can extend one or more other interfaces using the extends keyword. A class implementing the child interface must implement all methods from both the child and parent interfaces.
What is the difference between a default method and an abstract method in an interface?
An abstract method has no body — implementing classes must provide one. A default method has a body and acts as a fallback that implementing classes can optionally override.
What is interface segregation?
Interface segregation is the 'I' in SOLID. It means keeping interfaces small and focused. A class should not be forced to implement methods it does not need. Prefer many small interfaces over one large one.
What is a sealed interface in Java?
A sealed interface (Java 17) lists exactly which classes may implement it with a permits clause. Because the compiler knows every implementation, a switch over the interface type can be exhaustive with no default branch, and adding a new implementation flags every switch that does not handle it.