What you'll learn

  • What makes an interface "functional"
  • The six built-in interfaces you'll use constantly
  • Composing behaviour with andThen, compose, negate and and/or
  • Writing your own @FunctionalInterface

Java 8 changed how Java is written. Lambdas, method references and streams all rest on one simple idea: a functional interface — an interface with exactly one abstract method. Any lambda can stand in for any functional interface whose method has a matching shape. Understand these, and the rest of modern Java becomes easy to read.

What makes an interface functional

@FunctionalInterface
interface PriceRule {
    double apply(double price);          // the one abstract method

    default PriceRule then(PriceRule next) {   // default methods don't count
        return p -> next.apply(apply(p));
    }
}

@FunctionalInterface is optional, but it makes the compiler check that there really is only one abstract method — use it on your own interfaces.

Now any lambda that takes a double and returns a double is a PriceRule:

PriceRule discount = p -> p * 0.9;
PriceRule tax = p -> p * 1.2;
discount.then(tax).apply(100);           // 108.0

The built-in toolbox (java.util.function)

You rarely need your own interface: the JDK ships ones for every common shape.

InterfaceShapeMethodExample
Predicate<T>T → booleantestis this SKU valid?
Function<T, R>T → Rapplyorder → order ID
Supplier<T>() → Tgetcreate a run ID, a default value
Consumer<T>T → voidacceptlog, print, send
BiFunction<T, U, R>(T, U) → Rapply(qty, price) → total
UnaryOperator<T>T → Tapplytrim, uppercase
BinaryOperator<T>(T, T) → Tapplysum, max

There are also primitive versions — IntPredicate, ToDoubleFunction, IntSupplier… — which avoid wrapping numbers in objects (“boxing”) for speed.

Using them

Predicate<String> isSku = s -> s.matches("[A-Z]{2}-\\d{3}");
isSku.test("AX-200");                      // true

Function<String, Integer> length = String::length;
length.apply("Boomi");                     // 5

Supplier<String> runId = () -> "RUN-" + System.nanoTime();
Consumer<String> log = msg -> System.out.println("[log] " + msg);
BiFunction<Integer, Double, Double> lineTotal = (qty, price) -> qty * price;

Composition: building behaviour from small pieces

This is where functional interfaces shine. Small, tested functions combine into bigger ones:

// Function: andThen (this, then that) and compose (that, then this)
Function<String, Integer> lengthTimesTwo = length.andThen(n -> n * 2);

// Predicate: and, or, negate
Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> validAndNotBlank = notBlank.and(isSku);
Predicate<String> invalid = isSku.negate();

// Consumer: andThen
Consumer<String> logAndAudit = log.andThen(msg -> System.out.println("[audit] " + msg));

Think of them as LEGO bricks: each does one thing, and you snap them together into rules, pipelines and validations.

Why this matters in practice

  • Strategy without boilerplate. Pass behaviour (a Predicate for “which orders?”, a Function for “how to map?”) instead of writing a class per variation.
  • Configurable pipelines. Build a list of UnaryOperator<String> cleaners and apply them in order.
  • Lazy values. A Supplier computes only when asked — handy for expensive defaults and logging.
  • Every stream operation takes one of these interfaces: filter(Predicate), map(Function), forEach(Consumer), reduce(BinaryOperator).

The full example

// FunctionalInterfaces.java — the built-in functional interfaces and your own.
import java.util.function.*;

public class FunctionalInterfaces {

    // Your own functional interface: exactly one abstract method
    @FunctionalInterface
    interface PriceRule {
        double apply(double price);

        // default methods are allowed and don't count
        default PriceRule then(PriceRule next) {
            return p -> next.apply(apply(p));
        }
    }

    public static void main(String[] args) {
        // Predicate<T>: T -> boolean
        Predicate<String> isSku = s -> s.matches("[A-Z]{2}-\\d{3}");
        System.out.println(isSku.test("AX-200"));         // true
        System.out.println(isSku.negate().test("hello")); // true

        // Function<T, R>: T -> R
        Function<String, Integer> length = String::length;
        Function<Integer, Integer> doubled = n -> n * 2;
        System.out.println(length.andThen(doubled).apply("Boomi"));   // 10

        // Supplier<T>: () -> T  (lazy values)
        Supplier<String> runId = () -> "RUN-" + System.nanoTime() % 1000;
        System.out.println(runId.get().startsWith("RUN-"));           // true

        // Consumer<T>: T -> void
        Consumer<String> log = msg -> System.out.println("[log] " + msg);
        log.andThen(msg -> System.out.println("[audit] " + msg)).accept("order created");

        // BiFunction<T, U, R> and BinaryOperator<T>
        BiFunction<Integer, Double, Double> lineTotal = (qty, price) -> qty * price;
        BinaryOperator<Integer> max = BinaryOperator.maxBy(Integer::compare);
        System.out.println(lineTotal.apply(24, 8.5) + " " + max.apply(3, 9));   // 204.0 9

        // UnaryOperator<T>: T -> T
        UnaryOperator<String> shout = s -> s.toUpperCase() + "!";
        System.out.println(shout.apply("hello"));                    // HELLO!

        // Primitive specialisations avoid boxing
        IntPredicate even = n -> n % 2 == 0;
        System.out.println(even.test(4));                            // true

        // Our own interface, composed
        PriceRule discount = p -> p * 0.9;
        PriceRule tax = p -> p * 1.2;
        System.out.printf("%.2f%n", discount.then(tax).apply(100));  // 108.00
    }
}

Try it yourself

Create a list of UnaryOperator<String> “cleaners” — trim, uppercase, and replace spaces with dashes — then combine them into one Function<String, String> and apply it to " po 4471 ".

Show a solution
List<UnaryOperator<String>> cleaners = List.of(
        String::trim,
        String::toUpperCase,
        s -> s.replace(' ', '-'));

Function<String, String> clean = Function.identity();
for (UnaryOperator<String> c : cleaners) {
    clean = clean.andThen(c);
}
System.out.println(clean.apply("  po 4471 "));   // PO-4471

Function.identity() returns its input unchanged — a neutral starting point for chaining.

Key takeaways

  • A functional interface has one abstract method; any matching lambda implements it.
  • Learn the core six: Predicate, Function, Supplier, Consumer, BiFunction, UnaryOperator/BinaryOperator.
  • Compose with andThen, compose, and, or, negate.
  • Use @FunctionalInterface on your own.