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.
| Interface | Shape | Method | Example |
|---|---|---|---|
Predicate<T> | T → boolean | test | is this SKU valid? |
Function<T, R> | T → R | apply | order → order ID |
Supplier<T> | () → T | get | create a run ID, a default value |
Consumer<T> | T → void | accept | log, print, send |
BiFunction<T, U, R> | (T, U) → R | apply | (qty, price) → total |
UnaryOperator<T> | T → T | apply | trim, uppercase |
BinaryOperator<T> | (T, T) → T | apply | sum, 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
Predicatefor “which orders?”, aFunctionfor “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
Suppliercomputes 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
@FunctionalInterfaceon your own.