Composing functions and chaining predicates
Predicate chaining, stream filtering with lambda expressions, function composition, operators, comparator composition, rule engines and short-circuit behavior.
On this page
🔗 What is function and predicate composition?
Composition means building one bigger operation from smaller functions. A Predicate<Order> such as paid.and(domestic).and(notOnHold) becomes one rule that still reads like three business facts. A Function<Order, OrderDto> can be followed by andThen(enrichDto) so each step stays small.
For this lesson, we'll focus on Java Predicate chains, stream filtering with lambda expressions, Predicate.and/or/negate, Predicate.not, Predicate.isEqual, Function.andThen versus compose, Function.identity, UnaryOperator, BinaryOperator.minBy/maxBy, comparator composition, a list-driven rule engine, and short-circuit behavior. Interviewers ask this because the syntax looks friendly, but order, null handling and side effects reveal whether you understand what runs and when.
Below the line (out of scope)
- Functional interface families and lambda compilation: Lambdas and functional interfaces
- Capturing local variables in lambdas: Effectively final and variable capture
- Map sorting and everyday map operations: Everyday Map operations
- Full stream pipeline execution and laziness: How a stream pipeline executes
The 30-second answer
Predicate<T> has and, or, negate, the Java 11 static helper Predicate.not, and Predicate.isEqual. and and or short-circuit like && and ||, so the right-hand predicate may not run. Stream.filter accepts a predicate, so you can chain filters for readability or pass one combined predicate when you want a named rule. Function.andThen runs this function first and the next function second; compose runs the argument first and this function second. Function.identity() returns its input unchanged, UnaryOperator<T> is T -> T, and BinaryOperator.minBy/maxBy pick one of two values using a comparator. Comparator composition follows the same idea: choose a key with comparing, break ties with thenComparing, and be explicit about reversed and nullsFirst.
What you’ll learn
- Build a Java Predicate chain with
and,or,negate,notandisEqual - Choose between a Java Stream filter with lambda expression and a named combined predicate
- Explain
Function.andThenversusFunction.composewithout reversing the order - Use
Function.identity,UnaryOperator, andBinaryOperator.minByormaxByin realistic code - Compose comparators with
comparing,thenComparing,reversedandnullsFirst - Build a small rule engine from a
List<Predicate<T>>usingreduce(Predicate::and)and explain short-circuiting
Interview radar
Asked often: How do you chain predicates, and is filter(a).filter(b) different from filter(a.and(b))?
Core follow-up: What is the order difference between andThen and compose?
Production angle: A validation chain hides an expensive database-backed rule behind a cheap null check, or accidentally puts it first.
01Picture it first: security gates on one corridor
Picture an order walking down a corridor with gates. The first gate checks "paid", the second checks "in stock", and the third checks "not on hold". If the order fails the first gate, it never reaches the rest.
A function pipeline is the same corridor with machines instead of gates. One machine trims a customer code, the next uppercases it, and the last turns it into a lookup key. Order is the whole story.
Orders pass through chained predicate gates that can stop early, while customer codes pass through function machines where andThen runs left to right and compose runs right to left
Notice the two halves. Predicate composition decides whether an item survives. Function composition transforms the item into another value. Mixing those up is how candidates end up with clever code that nobody wants to maintain.
02Predicate chains under the hood
Try it yourself first
Before reading on, say which predicates run for each order. Does onHold run for an unpaid order?
import java.util.List;
import java.util.function.Predicate;
record Order(String id, boolean paid, boolean inStock, boolean onHold, int cents) { }
public class PredicateChainDemo {
public static void main(String[] args) {
Predicate<Order> paid = order -> order.paid();
Predicate<Order> inStock = order -> order.inStock();
Predicate<Order> notOnHold = Predicate.not(Order::onHold); // Java 11
Predicate<Order> canShip = paid.and(inStock).and(notOnHold);
List<Order> orders = List.of(
new Order("o-1", true, true, false, 1_200),
new Order("o-2", false, true, false, 900),
new Order("o-3", true, false, false, 600)
);
orders.stream()
.filter(canShip)
.map(Order::id)
.forEach(System.out::println); // o-1
}
}
Predicate.and returns a new predicate that calls the left side first. If the left side is false, it returns false without calling the right side. Predicate.or is the mirror image: if the left side is true, it returns true without calling the right side.
Notice three things:
- Short-circuiting is part of the contract. This is not a stream optimization; it is how
Predicate.andandPredicate.orare specified. - Put cheap guards first. Null checks, status flags and range checks should usually run before expensive lookups or regex work.
- A chain is still one predicate. You can pass
canShiptofilter,removeIf,allMatch,noneMatchor your own validation method.
negate() flips one predicate instance. Predicate.not(...) is a static helper added in Java 11, mostly to make method references readable. I prefer Predicate.not(Order::onHold) over Order::onHold.negate() because the latter is not valid syntax, and wrapping the method reference in a named predicate often adds noise.
import java.util.List;
import java.util.function.Predicate;
public class PredicateHelpersDemo {
public static void main(String[] args) {
Predicate<String> blank = String::isBlank;
Predicate<String> present = blank.negate();
Predicate<String> notBlank = Predicate.not(String::isBlank);
Predicate<String> isAtlas = Predicate.isEqual("atlas");
List<String> words = List.of("atlas", "", "java", " atlas ");
System.out.println(words.stream().filter(present).count()); // 3
System.out.println(words.stream().filter(notBlank).count()); // 3
System.out.println(words.stream().filter(isAtlas).count()); // 1
}
}
Predicate.isEqual(target) uses Objects.equals(target, value), so it is null-safe. Predicate.isEqual(null) returns a predicate that matches null values. That is handy in tests and in small stream cleanups, though in business logic I usually name the rule.
Java Stream filter with lambda expression
A stream filter with a lambda expression is often the first version people write, and it is a good default for one or two obvious conditions.
Try it yourself first
Before reading the two versions, choose the one you would want in a code review if the rule name appears in three services.
import java.util.List;
record Customer(String id, boolean active, String country, int riskScore) { }
public class StreamFilterDemo {
public static void main(String[] args) {
List<Customer> customers = List.of(
new Customer("c-1", true, "IN", 30),
new Customer("c-2", true, "US", 80),
new Customer("c-3", false, "IN", 10)
);
List<String> inline = customers.stream()
.filter(c -> c.active())
.filter(c -> c.country().equals("IN"))
.filter(c -> c.riskScore() < 70)
.map(Customer::id)
.toList();
System.out.println(inline); // [c-1]
}
}
Chaining filters is readable when each line is a small condition and the stream is local. It also gives you a nice debugging point: comment out one filter, or add a temporary peek while investigating. We cover the dangers of leaving peek in production in the stream side-effects lesson.
When the same rule needs a name, combine the predicates and pass the named rule to filter:
import java.util.List;
import java.util.function.Predicate;
record Customer(String id, boolean active, String country, int riskScore) { }
public class NamedPredicateDemo {
static Predicate<Customer> eligibleForCampaign() {
Predicate<Customer> active = Customer::active;
Predicate<Customer> inIndia = c -> c.country().equals("IN");
Predicate<Customer> lowRisk = c -> c.riskScore() < 70;
return active.and(inIndia).and(lowRisk);
}
public static void main(String[] args) {
List<Customer> customers = List.of(
new Customer("c-1", true, "IN", 30),
new Customer("c-2", true, "US", 80),
new Customer("c-3", false, "IN", 10)
);
List<String> ids = customers.stream()
.filter(eligibleForCampaign())
.map(Customer::id)
.toList();
System.out.println(ids); // [c-1]
}
}
The trade-off is naming versus locality. Inline filters keep the rule close to the data flow. A named predicate makes reuse, tests and interview discussion easier. My default is inline for one-off transformations, named predicates for domain rules, especially anything reused or tested separately.
filter(a).filter(b) and filter(a.and(b)) produce the same survivors for stateless predicates in a sequential stream. They are not identical as objects, stack traces or debugging units. With side effects in predicates, both versions are a smell, and the exact logging pattern can differ once short-circuiting and parallel execution enter the picture.
03Function composition under the hood
Try it yourself first
Before reading on, calculate both results. Which line applies tax before rounding?
import java.util.function.Function;
public class FunctionOrderDemo {
public static void main(String[] args) {
Function<Long, Long> addGst = cents -> cents * 118 / 100;
Function<Long, Long> roundToRupee = cents -> ((cents + 99) / 100) * 100;
Function<Long, Long> taxThenRound = addGst.andThen(roundToRupee);
Function<Long, Long> roundThenTax = addGst.compose(roundToRupee);
System.out.println(taxThenRound.apply(1_050L)); // 1300
System.out.println(roundThenTax.apply(1_050L)); // 1298
}
}
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- Advanced: comparators, rule engines and short-circuit traps
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal