Interfaces

Describe what a component does with an interface, implement it in several ways, and make services depend on the contract rather than a class.

Syntaxinterface Name { ReturnType method(Params p); } class Impl implements Name, Other { @Override public ReturnType method(Params p) { ... } }

An interface is a contract: a named set of methods with no fixed implementation. A class promises to provide them with implements. Code that only knows the interface can work with any class that keeps the promise, which is the basis of swapping implementations, testing with fakes and dependency injection.

interface UserRepository {
    Optional<User> findById(long id);
    void save(User user);
}

class InMemoryUserRepository implements UserRepository { ... }
class JdbcUserRepository implements UserRepository { ... }

The rules

  • Abstract methods in an interface are implicitly public abstract, so the implementing method must be public. Leaving it package-private is a common compile error (attempting to assign weaker access privileges).
  • Fields are implicitly public static final: constants, never per-object state.
  • A class extends one class but may implement any number of interfaces. An interface can extend several other interfaces.
  • Records and enums can implement interfaces too, which is common for DTOs and status codes.
  • An interface with exactly one abstract method is a functional interface and can be implemented with a lambda.

Programming to the interface

Declare variables, fields and parameters with the interface type: List<Order> orders = new ArrayList<>(), private final UserRepository users. The service then has no idea whether users come from memory, PostgreSQL or a remote API. In a test you pass an in-memory implementation; in production the framework passes the database one. Spring's dependency injection works this way: you ask for a type, usually an interface, and it supplies an implementation.

The JDK is full of interfaces you implement to plug into the language: Comparable for natural sort order, AutoCloseable for try-with-resources, Runnable and Callable for executors, Iterable for the for-each loop.

Mistakes people make

  • An interface for every class, each with a single implementation that will never change. Add one when there are two implementations or a test needs a fake, not by reflex.
  • Huge interfaces that force implementers to write methods they do not support. Several small interfaces are easier to implement and to fake.
  • Constant interfaces: an interface holding only constants so classes can implement it to get short names. Use a final class or an enum and import the names instead.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Inject a UserRepository interface into a service so tests use an in-memory map and production uses JDBC.
  • Define a PaymentGateway interface with one implementation per provider and pick one from configuration.
  • Implement AutoCloseable on a client wrapper so callers release sockets and connections with try-with-resources.

More examples

A lambda as the implementation

No class is needed when the behaviour is one short method. Marking the interface with @FunctionalInterface makes the compiler reject a second abstract method later.

Example · java
// One abstract method, so it is a functional interface.
@FunctionalInterface
interface PriceRule {
    long apply(long cents);
}

long checkout(long cents, List<PriceRule> rules) {
    for (PriceRule rule : rules) {
        cents = rule.apply(cents);
    }
    return cents;
}

void main() {
    PriceRule tenPercentOff = cents -> cents * 90 / 100;
    PriceRule shipping = cents -> cents < 5000 ? cents + 499 : cents;

    IO.println(checkout(4000, List.of(tenPercentOff, shipping)));
    IO.println(checkout(8000, List.of(tenPercentOff, shipping)));
}

// Output:
// 4099
// 7200

Comparable on a record

Implementing a JDK interface plugs your type into existing APIs such as Collections.sort and TreeMap. Break ties on a unique field: if compareTo returned 0 for two invoices with the same total, a TreeSet or TreeMap would treat them as duplicates and silently drop one.

Example · java
record Invoice(String number, long totalCents) implements Comparable<Invoice> {
    @Override
    public int compareTo(Invoice other) {
        // Largest total first; ties broken by the unique invoice number.
        int byTotal = Long.compare(other.totalCents, totalCents);
        return byTotal != 0 ? byTotal : number.compareTo(other.number);
    }
}

void main() {
    List<Invoice> invoices = new ArrayList<>(List.of(
        new Invoice("INV-1", 1_500),
        new Invoice("INV-2", 9_900),
        new Invoice("INV-3", 4_200)));

    Collections.sort(invoices);   // uses compareTo
    for (Invoice i : invoices) {
        IO.println(i.number() + " " + i.totalCents());
    }
}

// Output:
// INV-2 9900
// INV-3 4200
// INV-1 1500

AutoCloseable and try-with-resources

Implementing AutoCloseable is what lets the class appear in a try-with-resources header. close() runs before the catch block, so a failed query still releases the connection.

Example · java
class Connection implements AutoCloseable {
    private final String name;

    Connection(String name) {
        this.name = name;
        IO.println("open " + name);
    }

    String query(String sql) {
        if (sql.isBlank()) throw new IllegalArgumentException("empty query");
        return "rows for " + sql;
    }

    @Override
    public void close() { IO.println("close " + name); }
}

void main() {
    try (Connection c = new Connection("db-1")) {
        IO.println(c.query("select 1"));
    }
    try (Connection c = new Connection("db-2")) {
        c.query(" ");
    } catch (IllegalArgumentException e) {
        IO.println("failed: " + e.getMessage());
    }
}

// Output:
// open db-1
// rows for select 1
// close db-1
// open db-2
// close db-2
// failed: empty query

Discussion

  • Be the first to comment on this lesson.