Modern Java for Rust Engineers
Module 4 of 8 Intermediate 35 min

Interfaces, Default Methods, and the Trait Analogy

Prerequisites: generics-type-erasure

What You'll Learn

Why This Matters

If you are coming from Rust, interfaces are the first thing you will reach for — they look like traits. And in many ways they are: both define contracts that types must fulfill, both support default method implementations, both enable polymorphic code. But the analogy breaks down in important ways that will cause real bugs if you assume they are identical.

The most critical difference is not about syntax — it is about when and where you can implement an interface. In Rust, you can implement a trait for a type anywhere, at any time, as long as either the trait or the type is in your crate. In Java, a class declares which interfaces it implements at the point of its own definition. You cannot add an interface implementation to someone else's class after the fact. This single constraint shapes how Java libraries are designed and how you must structure your own code.

This module also introduces two terms that the rest of this plan uses constantly. You need their definitions before moving forward.

Core Concept

Defining Our Terms First

Note: This module canonically defines two terms used throughout all subsequent modules.

interface — A Java contract type declared with the interface keyword. It may have zero or more abstract methods, default methods (concrete implementations), and static methods. It cannot hold instance fields or have a constructor. A class declares that it implements an interface at the point of class definition.

functional interface — An interface with exactly one abstract method (SAM: Single Abstract Method), annotated with @FunctionalInterface. The presence of exactly one abstract method is what enables lambda syntax: the compiler can unambiguously map a lambda to that one method. Standard examples: Comparator<T>, Predicate<T>, Function<T, R>.

A SAM interface is a strict subset of interfaces. Every functional interface is an interface; not every interface is a functional interface.

Interfaces as Contracts

An interface declares what a class must be able to do, without saying how. Multiple interfaces can be implemented simultaneously — this is how Java achieves multiple inheritance of behavior without the ambiguity of multiple class inheritance.

// Java
interface Printable {
    void print();
}

interface Saveable {
    void save(String path);
}

class Invoice implements Printable, Saveable {
    @Override
    public void print() { /* ... */ }

    @Override
    public void save(String path) { /* ... */ }
}
// Rust equivalent
trait Printable {
    fn print(&self);
}

trait Saveable {
    fn save(&self, path: &str);
}

struct Invoice;

impl Printable for Invoice {
    fn print(&self) { /* ... */ }
}

impl Saveable for Invoice {
    fn save(&self, path: &str) { /* ... */ }
}

The structural difference is immediately visible: in Rust, impl Printable for Invoice is a separate block that can live in any file in your crate. In Java, implements Printable, Saveable is part of the class declaration itself. You cannot split them out.

Default Methods

Since Java 8, interfaces can include concrete method implementations using the default keyword. This was added primarily to let library authors add new methods to existing interfaces without breaking every class that already implemented them.

// Java
interface Writer {
    void write(String text);

    // Default method — implementing classes inherit this unless they override it
    default void writeLine(String text) {
        write(text + "\n");
    }
}
// Rust equivalent
trait Writer {
    fn write(&mut self, text: &str);

    fn write_line(&mut self, text: &str) {
        self.write(text);
        self.write("\n");
    }
}

These are nearly identical in purpose and syntax. The practical difference: Rust's default method can reference self with full type information, while Java's default method can only call the interface's own abstract or default methods — it cannot access any fields of the implementing class (because interfaces have no instance fields).

When should you use a default method instead of an abstract class? The rule is straightforward: use a default method when you need multiple inheritance of behavior and do not need shared state. Use an abstract class when you need instance fields or a constructor shared by all subclasses.

The Diamond Problem

When two interfaces define the same default method, and a class implements both, the compiler cannot choose for you:

// Java
interface Poet {
    default void write() { System.out.println("Poetry"); }
}

interface Engineer {
    default void write() { System.out.println("Code"); }
}

class EngineerPoet implements Poet, Engineer {
    // Compiler error if you do not resolve this
    @Override
    public void write() {
        Poet.super.write();  // Explicitly delegate to Poet's version
    }
}

The resolution syntax is InterfaceName.super.method(). You must pick one, implement your own, or call both explicitly. Rust avoids this problem structurally: each trait is implemented in its own impl block, so <EngineerPoet as Poet>::write() and <EngineerPoet as Engineer>::write() are always unambiguous.

Dynamic Dispatch

Dispatch: Defined

This module uses two dispatch terms consistently. Here are their canonical definitions side by side:

JavaRust
Dynamic dispatchRuntime method resolution via vtable lookup. The JVM inspects the object's actual runtime type and calls the right implementation. This is Java's default for interface method calls.Opt-in with dyn Trait.
Static dispatchJava has no language-level static dispatch. The JIT compiler may devirtualize monomorphic call sites at runtime as an optimization, but this is not a compile-time guarantee.Default via monomorphization: the compiler generates a separate version of any generic function for each concrete type argument. No runtime lookup needed.

Module 03 introduced both terms in the context of generics. This module is where dynamic dispatch becomes central to Java programming.

When you write validator.validate(payment) where validator is typed as an interface, the JVM looks up the actual implementation class of the object at runtime and calls the right method. This lookup happens every time the call site is reached, unless the JIT has devirtualized it.

In Java, calling a method through an interface reference is always dynamic dispatch. The JVM does optimize this aggressively — the just-in-time (JIT) compiler can inline monomorphic call sites (where only one implementation is ever used). But this is a runtime optimization, not a compile-time guarantee, and it breaks down at polymorphic call sites.

Rust comparison: In Rust, fn process<V: Validator>(v: &V, p: &Payment) uses static dispatch — the compiler generates process::<ConcreteValidator> with the call inlined. If you want dynamic dispatch in Rust, you opt in explicitly with dyn Validator. Java has no equivalent opt-in: interface calls are always dynamically dispatched, and you cannot ask the compiler to monomorphize for you.

Trait Comparison Table

FeatureJava interfaceRust trait
Instance fieldsNoNo
ConstructorNoNo
Default methodsYes (default keyword)Yes (method body in trait)
Implementation locationAt class definition onlySeparate impl Trait for Type anywhere in crate
Default dispatchDynamic (vtable)Static (monomorphization)
Explicit dynamic dispatchAlways (no choice)Opt-in with dyn Trait
Multiple boundsimplements A, BT: A + B
Adding to external typesNot possiblePossible (orphan rule permitting)

The most important row: "Implementation location." This is the constraint that shapes everything. If a third-party library gives you a Payment class, you cannot make it implement your Printable interface without subclassing or wrapping it. In Rust, you could just write impl Printable for Payment in your crate.

Functional Interfaces and Lambda Syntax

A functional interface has exactly one abstract method. That single-method constraint is what makes lambda syntax possible: when you write a lambda where a functional interface is expected, the compiler knows exactly which method to implement.

// Java
@FunctionalInterface
interface FraudDetector {
    boolean isSuspicious(Payment payment);
}

// Lambda works because FraudDetector has exactly one abstract method
FraudDetector detector = p -> p.amount() > 10_000;

The @FunctionalInterface annotation is optional — any interface with exactly one abstract method is a functional interface whether you annotate it or not. The annotation adds two things: documentation clarity, and a compile-time check that prevents you from accidentally adding a second abstract method later.

Standard library functional interfaces you will see constantly: Predicate<T> (one-argument boolean test), Function<T, R> (maps T to R), Consumer<T> (accepts T, returns nothing), Supplier<T> (takes nothing, produces T), Comparator<T> (compares two T values).

Rust comparison: Java's functional interfaces are structurally similar to Rust's Fn traits. FraudDetector above is roughly equivalent to impl Fn(&Payment) -> bool. The key difference: Rust's Fn/FnMut/FnOnce hierarchy captures ownership and mutability semantics that Java has no equivalent for. Java lambdas can only close over effectively-final variables (Module 07 covers this in depth).

Sealed Interfaces

A sealed interface restricts which types may implement it. You declare the permitted types in a permits clause:

// Java
sealed interface PaymentResult permits PaymentSuccess, PaymentFailure {}

Every type named in permits must implement the interface. No other type can implement it. This means the compiler knows the complete set of possible implementations at compile time — which enables exhaustive switch expressions (Module 05 covers the full pattern matching syntax):

// Java — the compiler verifies all cases are handled
String summary = switch (result) {
    case PaymentSuccess s -> "Success: " + s.transactionId();
    case PaymentFailure f -> "Failed: " + f.reason();
    // No default needed — compiler knows these are all possibilities
};

Note: Both sealed class and sealed interface are sealed type hierarchies and use the same sealed and permits keywords. The only difference is the declaration target (class vs. interface). Module 05 introduces sealed classes and will refer back to this definition.

Rust comparison: This is the closest Java gets to a Rust enum. Not in syntax, but in what the compiler guarantees: a sealed interface with N permitted types means the compiler can verify you handled all N cases in a switch expression. The difference is that Rust enums hold data per variant directly; Java's sealed interface permits separate record types that each carry their own fields.

The permitted types must either be in the same package as the sealed interface, or declared in the same file. Each permitted type must declare itself as final, sealed, or non-sealed:

Concrete Example

The running example for this module: define PaymentValidator as an interface with a default method, and FraudDetector as a functional interface.

// Java
package com.example.payments;

// A general interface — not a SAM interface
// Has one abstract method and one default method
public interface PaymentValidator {

    // The contract: implementing classes must provide this
    boolean validate(Payment payment);

    // Default method built on top of the abstract method
    // Note: cannot access any fields from implementing classes here
    default boolean isValid(Payment payment) {
        return validate(payment);
    }
}

// A functional interface — exactly one abstract method
// The @FunctionalInterface annotation enforces this at compile time
@FunctionalInterface
public interface FraudDetector {
    boolean isSuspicious(Payment payment);
}

Now using them:

// Java
// An implementation of PaymentValidator — must provide validate()
public class BasicPaymentValidator implements PaymentValidator {

    @Override
    public boolean validate(Payment payment) {
        if (payment.amount() <= 0) return false;
        if (payment.fromAccount() == null || payment.toAccount() == null) return false;
        return true;
    }

    // isValid() is inherited from PaymentValidator — no override needed
}

// FraudDetector implemented via lambda — works because it is a SAM interface
FraudDetector highValueDetector = p -> p.amount() > 10_000;

// Dynamic dispatch in action: the JVM looks up the actual implementation at runtime
PaymentValidator validator = new BasicPaymentValidator();
boolean valid = validator.isValid(new Payment(1L, "ACC-001", "ACC-002", 500.0, "USD"));

When validator.isValid(payment) is called, the JVM checks the actual runtime type of validator (it is a BasicPaymentValidator) and dispatches to the correct validate() implementation. This happens at runtime, not compile time.

Analogy

Think of a Java interface as a job posting, and a class as a person applying for the job. The job posting (interface) lists required skills (abstract methods) and may include standard operating procedures that come with the role (default methods). When someone is hired (the class is declared), they must explicitly list which job postings they satisfy on their application (the implements clause at class definition time).

In Rust, the job posting system works differently: a person can add new certifications (trait impls) any time after they are hired, as long as either the certification program or the person is "from your organization" (the orphan rule). Java requires all certifications to be declared upfront.

Sealed interfaces add a twist: the hiring manager knows exactly who is eligible and lists them by name in the job posting (permits clause). No surprises at runtime.

Going Deeper

Why Can't You Implement Interfaces Retroactively?

This limitation exists because Java's type system is built around nominal typing: a class is a subtype of an interface only if the class explicitly declares implements InterfaceName. The runtime's vtable is built from these declarations.

If you could add interface implementations retroactively, the JVM's dispatch tables would need to be rebuilt dynamically, class loading semantics would become significantly more complex, and binary compatibility guarantees would break. Rust's trait system avoids this problem because trait impls are monomorphized at compile time — there is no dynamic dispatch table to invalidate.

This is the design choice, not a limitation to be worked around. Java's predictability — you can always look at a class declaration and know exactly which interfaces it satisfies — is considered a feature in large codebases.

Interface Static Methods and Private Methods

Since Java 8, interfaces can also have static methods, which belong to the interface type itself (not to any implementing class):

// Java
interface PaymentValidator {
    static PaymentValidator noOp() {
        return p -> true;  // always valid — a no-op implementation
    }
}

Since Java 9, interfaces can have private methods — used to share implementation between default methods without exposing the helper as part of the interface's public API.

Abstract Class vs. Interface with Default Methods

The modern rule: reach for an interface with default methods first. Only use an abstract class when you need:

  1. Instance state (fields shared by all subclasses).
  2. A constructor that initializes shared state.
  3. Access control weaker than public for methods (protected methods; interface methods are always effectively public).

A class can implement many interfaces but extend only one class. Abstract class inheritance is a scarce resource — do not spend it on something an interface can provide.

Common Misconceptions

1. "Implementing multiple interfaces always causes the diamond problem."

The diamond problem only occurs when two interfaces define a default method with the same signature. If the conflicting methods are both abstract (no implementation), there is no conflict — the implementing class simply provides one concrete implementation that satisfies both. The diamond problem is narrowly scoped to default methods with the same name and parameter list.

2. "Java interfaces are just like Rust traits."

The surface similarity is real; the differences are important. Beyond dispatch (dynamic vs. static), the key structural difference is implementation location. In Rust, you can implement a trait for a type in your crate regardless of where the type or trait was defined (within the orphan rules). In Java, you must own the class definition to add interface implementations. This means if a library gives you a Payment class that does not implement Printable, your only options are subclassing or wrapping — you cannot add the implementation externally.

3. "A sealed interface prevents the types from doing anything else."

Sealing an interface only restricts who can implement it. The permitted types are free to implement other interfaces, have their own inheritance hierarchy, and contain whatever methods and fields they need. Sealing gives the compiler a closed, exhaustive list for pattern matching — it does not otherwise constrain the types' behavior.

Check Your Understanding

  1. Why can't you add a second abstract method to a @FunctionalInterface and still use it with lambda syntax?

    Answer: Lambda syntax requires the compiler to know exactly which method to implement. With two abstract methods, the compiler has no way to determine which one a given lambda is meant to implement. The @FunctionalInterface annotation causes a compile error if you try, catching this mistake early.

  2. You have two interfaces, Auditable and Loggable, both with a default void record() method. You write a class that implements both. What happens, and how do you fix it?

    Answer: The compiler rejects the class with a compilation error because it cannot determine which default record() to inherit. You fix it by overriding record() in the implementing class and explicitly delegating using Auditable.super.record() and/or Loggable.super.record() as needed.

  3. You have a PaymentValidator validator variable typed as the interface. What determines which validate() implementation is called, and when is that determination made?

    Answer: Dynamic dispatch determines the implementation. The determination is made at runtime — the JVM inspects the actual object stored in validator (its runtime type) and looks up the correct validate() in that class's vtable. The compile-time type (PaymentValidator) only tells the compiler which methods are available; it does not determine which code runs.

  4. In Rust, you can write impl MyTrait for ExternalType in your crate. What is the closest Java alternative when you need a third-party class to satisfy an interface you define?

    Answer: Java provides no direct equivalent. Your options are: (a) the Adapter pattern — write a wrapper class that implements your interface and delegates to the external type; (b) subclassing — extend the external class and add implements YourInterface (only if the class is not final). Neither is as clean as a Rust trait impl.

  5. You declare sealed interface Shape permits Circle, Rectangle {}. A colleague wants to add Triangle to the system. What change is required, and what does the compiler force them to do afterward?

    Answer: They must add Triangle to the permits clause: sealed interface Shape permits Circle, Rectangle, Triangle {}. The compiler then reports an error at every exhaustive switch expression over Shape that does not handle the Triangle case — ensuring all call sites are updated before the code compiles.

Key Takeaways

References