Lambdas & Functional Interfaces

Composing Behaviour with Lambdas

15 min Lesson 9 of 13

Composing Behaviour with Lambdas

Passing a lambda to a method means you are passing behaviour — a small, nameless unit of logic — instead of a value. Methods that accept or return other functions are called higher-order methods. Together, these two ideas let you write code that is general-purpose but still precisely expressive at every call site.

Why pass behaviour at all?

Consider filtering a list. You could write filterByAge, filterByName, filterByCity — one method per rule. Or you could write a single filter method that accepts the rule as a Predicate and applies it to whatever list you give it. The second approach is shorter, reusable, and open to rules you have not invented yet.

Higher-order method definition: a method is higher-order when it takes a function as a parameter, returns a function, or both. Every method in the Stream API — filter, map, sorted — is higher-order.

Writing your first higher-order method

Here is a standalone utility that applies a transformation to every element of a list and returns a new list. The transformation is expressed as a Function<T, R> passed by the caller.

import java.util.ArrayList; import java.util.List; import java.util.function.Function; public class HigherOrderDemo { // Higher-order: accepts behaviour as a parameter public static <T, R> List<R> transform(List<T> items, Function<T, R> mapper) { List<R> result = new ArrayList<>(); for (T item : items) { result.add(mapper.apply(item)); } return result; } public static void main(String[] args) { List<String> names = List.of("alice", "bob", "carol"); // Pass different behaviours to the same method List<String> upper = transform(names, String::toUpperCase); List<Integer> lengths = transform(names, String::length); System.out.println(upper); // [ALICE, BOB, CAROL] System.out.println(lengths); // [5, 3, 5] } }

The same transform method produced two completely different results because the behaviour was swapped, not the method. That is the core power of higher-order programming.

Returning a function from a method

A method can also build and return a lambda. This is called a factory for behaviour: you call the method with some configuration, and it hands back a ready-to-use function.

import java.util.function.Predicate; public class BehaviourFactory { // Returns a Predicate that checks whether a string starts with the given prefix public static Predicate<String> startsWith(String prefix) { return s -> s.startsWith(prefix); } // Returns a Predicate that checks minimum length public static Predicate<String> longerThan(int minLength) { return s -> s.length() > minLength; } public static void main(String[] args) { Predicate<String> isJava = startsWith("Java"); Predicate<String> isLong = longerThan(5); System.out.println(isJava.test("JavaScript")); // true System.out.println(isJava.test("Python")); // false System.out.println(isLong.test("Hi")); // false System.out.println(isLong.test("Lambdas")); // true // Combine using Predicate combinators — see Lesson 4 Predicate<String> javaAndLong = isJava.and(isLong); System.out.println(javaAndLong.test("JavaScript")); // true System.out.println(javaAndLong.test("Java")); // false (length 4) } }
Capture configuration, not state. The lambda returned by startsWith("Java") captures the prefix variable. That variable is effectively final once the method returns — the lambda is safe to store, share, and call later from any thread. This is the correct way to parameterise behaviour.

Composing a pipeline by chaining higher-order calls

You can pass the result of one higher-order call directly into another, building a readable, declarative pipeline without any intermediate variables:

import java.util.List; import java.util.function.Predicate; import java.util.stream.Collectors; public class PipelineDemo { public static <T> List<T> filterAndCollect(List<T> source, Predicate<T> condition) { return source.stream() .filter(condition) .collect(Collectors.toList()); } public static void main(String[] args) { List<String> languages = List.of("Java", "JavaScript", "Python", "Kotlin", "Julia"); // Build a composed predicate inline and pass it straight to the method List<String> result = filterAndCollect( languages, BehaviourFactory.startsWith("J").and(BehaviourFactory.longerThan(4)) ); System.out.println(result); // [JavaScript, Kotlin ... wait — only J-starters longer than 4] // JavaScript (10) and Julia (5 — length IS > 4) — but Kotlin does not start with J // Actual output: [JavaScript, Julia] } }

A practical example: a sortable, filterable report

Real applications often need to sort and filter in ways that are not known at compile time. Passing a Comparator and a Predicate as parameters keeps the method general.

import java.util.*; import java.util.function.Predicate; import java.util.stream.Collectors; public record Product(String name, double price, String category) {} public class ReportEngine { public static List<Product> buildReport( List<Product> products, Predicate<Product> filter, Comparator<Product> sorter) { return products.stream() .filter(filter) .sorted(sorter) .collect(Collectors.toList()); } public static void main(String[] args) { List<Product> catalogue = List.of( new Product("Laptop", 999.0, "Electronics"), new Product("Phone", 499.0, "Electronics"), new Product("Desk", 250.0, "Furniture"), new Product("Monitor", 350.0, "Electronics") ); // Caller decides the rule and the order — the method never changes List<Product> report = buildReport( catalogue, p -> p.category().equals("Electronics") && p.price() < 600, Comparator.comparingDouble(Product::price) ); report.forEach(p -> System.out.printf("%s $%.2f%n", p.name(), p.price())); // Phone $499.00 // Monitor $350.00 } }
Do not overdo it. Higher-order methods shine when the variation point is genuinely open-ended or reused from many call sites. If a method is only ever called with one fixed lambda, an ordinary method is clearer. Use this pattern where the flexibility earns its complexity.

Composing functions with andThen and compose

The Function interface provides two built-in composition helpers. f.andThen(g) creates a new function that applies f first, then feeds the result to g. f.compose(g) does the reverse — g first, then f.

import java.util.function.Function; public class FunctionComposition { public static void main(String[] args) { Function<String, String> trim = String::strip; Function<String, String> lower = String::toLowerCase; Function<String, Integer> length = String::length; // Pipeline: trim → lower → length Function<String, Integer> pipeline = trim.andThen(lower).andThen(length); System.out.println(pipeline.apply(" Hello World ")); // 11 // Return a composed function from a helper method System.out.println(normalize().apply(" JAVA ")); // "java" } // Higher-order: returns a composed Function public static Function<String, String> normalize() { return ((Function<String, String>) String::strip).andThen(String::toLowerCase); } }

Summary

Higher-order methods treat lambdas as first-class arguments and return values. This lets you write one general algorithm and supply the variable behaviour at the call site. Combine that with the composition helpers — andThen, compose, and, or, negate — and you can build rich, readable behaviour pipelines without extra classes or repeated logic.