Passing code as a parameter in Java 8
-
Last Updated: July 21, 2023
-
By: javahandson
-
Series

In this article we will learn passing code as a parameter in Java 8, the design idea that turns changing requirements into a one line change. We will start with copy pasted filter methods, move to an interface, then to anonymous classes, and finally see how a lambda finishes the job.
Think about how you order coffee. You do not walk behind the counter and brew it yourself. You hand the barista a short instruction, and the barista runs it later. Your instruction travelled as a parameter.
Java code can travel the same way. We have always passed numbers, strings and objects into a method. Passing a piece of behaviour felt clumsy until Java 8 arrived, and the whole Stream API later grew on top of that one improvement.
This idea carries a formal name too. People call it behaviour parameterization. The name sounds heavy, yet the meaning stays simple: your method keeps the boring part, and the caller supplies the interesting part.
You need only basic Java here. If you can write a class, a loop and an if block, you have enough.
This article stays on the design idea. For lambda syntax, scope rules and the meaning of this, our Lambda Expression in Java 8 article covers all of it in depth.
Requirements never sit still. Your manager asks for one report today and a slightly different one tomorrow. Good code should absorb that without a rewrite.
We will use one small class through the whole article. An Employee has a name, a designation and a salary.
package com.javahandson;
public class Employee {
private String name;
private String designation;
private double salary;
public Employee(String name, String designation, double salary) {
this.name = name;
this.designation = designation;
this.salary = salary;
}
public String getName() { return name; }
public String getDesignation() { return designation; }
public double getSalary() { return salary; }
@Override
public String toString() {
return "Employee[name=" + name + ", designation=" + designation
+ ", salary=" + salary + "]";
}
}Now the requests start arriving. On Monday your manager wants every employee whose designation is Manager. On Tuesday the ask changes to everyone earning more than 50000. By Friday it becomes both conditions together.
Most of us solve this the obvious way. We write a filter method for the first rule.
public static List<Employee> filterByDesignation(List<Employee> employees) {
List<Employee> result = new ArrayList<>();
for (Employee employee : employees) {
if (employee.getDesignation().equals("Manager")) {
result.add(employee);
}
}
return result;
}Then the salary rule arrives, so we copy the method and change one line.
public static List<Employee> filterBySalary(List<Employee> employees) {
List<Employee> result = new ArrayList<>();
for (Employee employee : employees) {
if (employee.getSalary() > 50000) {
result.add(employee);
}
}
return result;
}Friday brings the combined rule, and we copy the method a third time.
public static List<Employee> filterByDesignationAndSalary(List<Employee> employees) {
List<Employee> result = new ArrayList<>();
for (Employee employee : employees) {
if (employee.getDesignation().equals("Manager")
&& employee.getSalary() > 50000) {
result.add(employee);
}
}
return result;
}Look at the three methods side by side. Almost every line repeats. Only the condition inside the if differs.
That duplication costs real money over time:
One rule of thumb explains everything that follows. Separate the part that stays the same from the part that keeps changing. Here the loop stays the same and the condition changes.
So how do we hand a condition to a method? That question sits at the heart of this article.
Passing code as a parameter means you wrap a block of logic, hand it to another method, and let that method run the block whenever it wants.
You never run the block yourself. You only describe it. The receiving method decides the timing, the order, and how many times it runs.
That last sentence deserves a pause. Control moves to the other side. You supply the what, and the method owns the when.
Picture a vending machine with a slot for instruction cards. The machine always does the same three things: pick an item, read your card, drop the item if the card says yes.
You write “only chocolate” on one card. Tomorrow you write “anything under fifty rupees” on another. The machine never changes. Your card carries the rule.
Our filter method is that machine. The condition is the card.
We already pass data all the time. A String name, an int count and a List of employees all count as data.
Behaviour differs. Behaviour answers a question or performs an action. “Does this employee earn above 50000?” counts as behaviour.
Java has no raw code type, so behaviour travels inside an object. Every stage below wraps the same condition in an object, and each stage simply wraps it with less typing than the previous one.
Our changing part always answers yes or no about one employee. An interface with a single boolean method captures that perfectly.
package com.javahandson;
public interface EmployeePredicate {
boolean test(Employee employee);
}A predicate is simply a test that returns true or false. The word comes from logic, and Java borrowed it for exactly this purpose.
Notice the interface holds one abstract method and nothing else. Keep that detail in mind, because it becomes important in section 7.
Each rule now becomes a tiny class.
package com.javahandson;
public class ManagerPredicate implements EmployeePredicate {
@Override
public boolean test(Employee employee) {
return employee.getDesignation().equals("Manager");
}
}
class HighSalaryPredicate implements EmployeePredicate {
@Override
public boolean test(Employee employee) {
return employee.getSalary() > 50000;
}
}Both classes hold no state. They only answer a question about the employee you hand them.
Here comes the payoff. We write the loop once and accept the rule as a parameter.
public static List<Employee> filterEmployees(List<Employee> employees,
EmployeePredicate predicate) {
List<Employee> result = new ArrayList<>();
for (Employee employee : employees) {
if (predicate.test(employee)) { // the caller's code runs right here
result.add(employee);
}
}
return result;
}
List<Employee> managers = filterEmployees(list, new ManagerPredicate());
List<Employee> wellPaid = filterEmployees(list, new HighSalaryPredicate());Read that comment line again. The caller supplied the logic, and our loop triggers it. That single line shows passing code as a parameter in action.
The design improved a lot. The typing did not.
Count what one rule costs us. A file, a package line, a class declaration, an @Override, a method signature and a closing brace. All of that wraps a single comparison.
Ten rules mean ten files. Your project explorer fills with classes named after conditions, and none of them holds real logic.
Look at HighSalaryPredicate once more. Eight lines exist so one comparison can travel to a method.
Programmers call this ceremony. The compiler wants it, but a reader gains nothing from it. Good code keeps ceremony low and meaning high.
There is a second cost, and it hurts more than the typing. The condition now sits in another file.
Someone reading filterEmployees(list, new HighSalaryPredicate()) cannot tell what the rule does. They must open a second file to find one comparison. Reading code beats writing code in frequency, so this matters.
Java gave us anonymous classes long before Java 8. They let you declare and create a class in one expression, without naming it.
List<Employee> result = filterEmployees(list, new EmployeePredicate() {
@Override
public boolean test(Employee employee) {
return employee.getDesignation().equals("Manager")
&& employee.getSalary() > 50000;
}
});No extra file appears now. The rule sits right where you use it, so section 5.3 stops being a problem.
Count the characters again. The words new EmployeePredicate(), @Override and public boolean test(Employee employee) add nothing new. Your method signature already announced all three.
Six lines of scaffolding still surround one comparison. Java developers lived with that trade for years, and libraries stayed shy about accepting behaviour because callers hated writing it.
Java 8 removed the last of the ceremony. Everything you designed in section 4 stays exactly as it was.
List<Employee> engineers =
filterEmployees(list, e -> e.getDesignation().equals("Engineer"));
List<Employee> seniorManagers =
filterEmployees(list, e -> e.getDesignation().equals("Manager")
&& e.getSalary() > 50000);
System.out.println(engineers);
// Output: [Employee[name=Amar, designation=Engineer, salary=40000.0]]Six lines shrank to one, and the rule still sits at the call site. A reader now grasps the whole thing in a glance.
This point matters more than the shorter syntax. Compare the two calls:
filterEmployees keeps the same signature it had in section 4.3EmployeePredicate keeps the same single methodA lambda is not a new mechanism. It is a shorter way to hand over the same object your interface always expected, which is why the whole pattern survived into Java 8 unchanged.
Section 4.1 asked you to remember one detail. EmployeePredicate declares exactly one abstract method.
That shape earns a name: a functional interface. Java 8 accepts a lambda only where such an interface appears, because one method leaves no ambiguity about what your lambda implements.
Our Custom Functional Interface in Java article covers the rules, the @FunctionalInterface annotation and what counts as abstract.
Lambdas carry a few rules of their own. None of them changes the design idea above, so we point you to the right place instead of repeating them:
this live in that same articlePredicate and Function live in Predefined Functional InterfacesEmployee::getName lives in Method Reference in Java 8We travelled through four styles. This table puts them next to each other.
| Stage | Lines per rule | Extra files | Rule visible at call site | Best for |
|---|---|---|---|---|
| Separate filter methods | 9 or more | None | No, the rule hides inside | One rule that never changes |
| Named predicate classes | 8 or more | One per rule | Only through the class name | Rules you reuse and test alone |
| Anonymous classes | 6 | None | Yes, buried under scaffolding | Java 7 and older projects |
| Lambda expressions | 1 | None | Yes, and it reads like a sentence | Java 8 and newer, everyday use |
Stage two fixed the real problem. Duplication disappeared the moment the loop accepted a rule as a parameter.
Stages three and four fixed nothing about the design. They only removed typing. That distinction helps in interviews, because people often credit lambdas with a change they did not make.
Lambdas are not always the answer. Reach for a named class when the rule spans many lines, when several teams share it, or when the rule deserves its own unit test.
A good name also documents intent. EligibleForBonusPredicate tells a story that a five line lambda cannot.
Filtering employees was only an example. The same four steps work on any algorithm that keeps growing new variants.
Put two versions of your method side by side and read them line by line. The lines that match form the stable skeleton. The lines that differ form your behaviour.
In our example three methods matched on nine lines and differed on one. That ratio told us exactly what to extract.
Say the varying part out loud in one short phrase. “Does this employee qualify?” names a question, so a boolean method fits.
“Print this employee” names a job with no answer, so a void method fits. “Turn this employee into a label” names a transformation, so a method with a return type fits.
Java already ships interfaces for the common shapes, so check those before writing your own. Write your own when the parameter names carry meaning that a generic type would lose.
// your own, when the domain wording helps a reader
public interface EmployeePredicate {
boolean test(Employee employee);
}
// or the ready made one, when the shape is all you need
import java.util.function.Predicate;
Predicate<Employee> rule = e -> e.getSalary() > 50000;Write the stable part once, then add one parameter for the behaviour. Making the method generic often costs nothing and widens its reach.
public static <T> List<T> filter(List<T> items, Predicate<T> rule) {
List<T> result = new ArrayList<>();
for (T item : items) {
if (rule.test(item)) {
result.add(item);
}
}
return result;
}
List<Employee> managers = filter(staff, e -> e.getDesignation().equals("Manager"));
List<String> longNames = filter(names, n -> n.length() > 5);Four yes answers mean you found a good candidate. A no on the third question usually means you should leave the code alone.
A yes or no rule is only the first shape. Three more show up constantly in real projects.
Sometimes the varying part performs a job and answers nothing. Printing, logging and saving all fit that description.
public static <T> void forEachItem(List<T> items, Consumer<T> action) {
for (T item : items) {
action.accept(item);
}
}
forEachItem(staff, e -> System.out.println(e.getName()));
forEachItem(staff, e -> log.info("processing {}", e.getName()));Here is the shape that wins the most real code. Some tasks need identical setup and cleanup around a small piece of varying work.
Opening a file, starting a transaction, acquiring a lock and timing a block all share that structure. The setup and cleanup never change. Only the middle changes.
public interface BufferedReaderProcessor {
String process(BufferedReader reader) throws IOException;
}
public static String readFile(String path, BufferedReaderProcessor task)
throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return task.process(reader); // only this part varies
}
}
String firstLine = readFile("data.txt", reader -> reader.readLine());
String twoLines = readFile("data.txt", reader -> reader.readLine()
+ reader.readLine());Notice what the caller never writes. No try, no close, no leaked file handle on an exception. Your method guarantees the cleanup, and the caller supplies only the interesting middle.
A callback flips the timing further. You hand over code now, and the method runs it much later, often after an event or a network reply.
public static void loadReport(String id,
Consumer<String> onSuccess,
Consumer<Exception> onFailure) {
try {
onSuccess.accept(fetch(id));
} catch (Exception ex) {
onFailure.accept(ex);
}
}
loadReport("R-100",
text -> System.out.println("got " + text.length() + " chars"),
error -> System.out.println("failed: " + error.getMessage()));Button listeners in Swing and Android work exactly this way. You supply the reaction, and the framework picks the moment.
Once you know the shape, you start seeing it everywhere in the standard library:
List.removeIf keeps the loop and takes your testList.sort keeps the sorting algorithm and takes your comparisonMap.computeIfAbsent keeps the lookup and takes your value builderStream.filter and Stream.map keep the traversal and take your ruleOptional.orElseGet keeps the null check and takes your fallbackEvery one of those methods hides a loop or a check you no longer write. Our Filtering in Streams article picks up the story from here.
Interviewers love this connection. Two classic patterns describe what we built, and Java 8 shrank both of them.
Map our code onto the classic vocabulary and it lines up perfectly:
EmployeePredicate plays the strategy interfaceManagerPredicate and its siblings play the concrete strategiesfilterEmployees plays the context that runs a strategyTextbooks drew this pattern with three boxes and a lot of classes. A lambda collapses each concrete strategy into one line, so the pattern survives while its boilerplate disappears.
Template Method solves a similar problem with inheritance. A parent class fixes the algorithm and leaves one abstract hook for a subclass.
Behaviour parameterization solves it with a parameter instead. That difference carries real weight:
No, and that makes a good answer in an interview. The patterns describe a design, not an amount of typing.
Java 8 lowered the cost of applying them until the cost almost vanished. Cheap patterns get used far more often, which explains why modern Java code passes behaviour around so freely.
Writing such a method takes a little care. Four habits keep your callers happy.
A trailing lambda reads far better, especially a multi line one. Every JDK method follows this convention, so your API will feel familiar.
// awkward, the data hides after a block of code filter(e -> e.getSalary() > 50000, staff); // natural, the block trails at the end filter(staff, e -> e.getSalary() > 50000);
Call it rule, action, printer or fallback. Those words tell a caller what their code must do.
Avoid names such as func, arg2 or lambda. They repeat the type and teach nobody anything.
Your caller lost control of the timing, so give it back in the documentation. State whether the code runs once per element, only on failure, or possibly never.
Also mention threads. A caller who learns about parallel execution afterwards has already shipped a race condition.
One behaviour parameter reads well. Two still work when the roles differ clearly, as our success and failure callbacks did.
Four unnamed lambdas at one call site turn unreadable. At that point a small interface with named methods serves everyone better.
These trip up almost everyone once. Reading them now saves an afternoon later.
The pattern earns its keep only when variants keep arriving. One rule with no second version in sight needs a plain method.
Premature abstraction adds an interface, a parameter and a layer of indirection for nothing. Wait for the second requirement, then extract.
Many developers reach for a boolean or an enum first. That choice looks smaller and ages terribly.
// the flag approach: every new rule edits this method
public static List<Employee> filter(List<Employee> staff, boolean byDesignation) {
for (Employee e : staff) {
if (byDesignation ? e.getDesignation().equals("Manager")
: e.getSalary() > 50000) { ... }
}
}
filter(staff, true); // what does true mean here?Read that call site. true explains nothing, and the method grows a new branch for every requirement. Pass the rule and both problems vanish.
A test should answer a question and change nothing else. Saving, logging or list building inside a predicate surprises everyone who reads the call.
// avoid: the "rule" also writes to a list
filter(staff, e -> { audit.add(e); return e.getSalary() > 50000; });
// prefer: filter first, then act
List<Employee> matched = filter(staff, e -> e.getSalary() > 50000);
audit.addAll(matched);Your code no longer runs in the order you wrote it. The receiving method might skip it, repeat it, delay it, or run it on another thread.
Short circuiting causes the classic surprise. A method that stops at the first match never calls your rule for the remaining items, so counters inside the rule end up wrong.
Your rule runs once per element. A database call or a file read inside it turns a cheap loop into a slow one.
// one query per employee
filter(staff, e -> repository.findGrade(e.getName()).equals("A"));
// one query in total
Set<String> topGrades = repository.findAllWithGradeA();
filter(staff, e -> topGrades.contains(e.getName()));A twenty line lambda defeats the purpose. The call site should read like a sentence, not like a second method.
Extract the logic into a private method with a clear name, then pass that method instead. Readers get a summary at the call site and the detail nearby.
Time to tie everything together. We will build a tiny report tool that prints any group of employees under any heading.
package com.javahandson;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Predicate;
public class ReportTool {
public static <T> List<T> filter(List<T> items, Predicate<T> rule) {
List<T> result = new ArrayList<>();
for (T item : items) {
if (rule.test(item)) {
result.add(item);
}
}
return result;
}
public static <T> void report(String heading, List<T> items,
Predicate<T> rule, Consumer<T> printer) {
System.out.println("== " + heading + " ==");
for (T item : filter(items, rule)) {
printer.accept(item);
}
System.out.println();
}
public static void main(String[] args) {
List<Employee> staff = Arrays.asList(
new Employee("Amar", "Engineer", 40000),
new Employee("Iqbal", "Manager", 60000),
new Employee("Suchit", "Manager", 55000),
new Employee("Kartik", "Clerk", 35000));
report("Managers", staff,
e -> e.getDesignation().equals("Manager"),
e -> System.out.println(e.getName()));
report("Senior managers", staff,
e -> e.getDesignation().equals("Manager") && e.getSalary() > 50000,
e -> System.out.println(e.getName() + " : " + e.getSalary()));
report("Everyone else", staff,
e -> !e.getDesignation().equals("Manager"),
e -> System.out.println(e.getName() + " (" + e.getDesignation() + ")"));
}
}== Managers == Iqbal Suchit == Senior managers == Iqbal : 60000.0 Suchit : 55000.0 == Everyone else == Amar (Engineer) Kartik (Clerk)
report method takes two blocks of code, one rule and one printerreport controls bothreportExtend the program with three small exercises:
Consumer that writes each row into a StringBuilder instead of the consolereport in an execute around method that prints the elapsed timeThe third exercise reuses section 10.2 directly. Fixed setup, fixed cleanup, and your code in the middle.
A: It means you wrap a block of logic, hand it to another method, and let that method run the block later. The caller supplies the varying behaviour, and the method supplies the stable skeleton around it, such as a loop or a try block.
A: Behaviour parameterization is the formal name for this technique. You split an algorithm into the part that stays the same and the part that keeps changing, then accept the changing part as a parameter. Our single filter method with a swappable rule shows it well.
A: It removes duplication caused by changing requirements. Without it, each new rule needs a near identical copy of the same method, so one bug in the loop spreads across every copy and every copy needs its own test.
A: An ordinary object carries values that your method reads. A behaviour object carries logic that your method invokes, so control passes back to the caller’s code at a moment the method chooses. The object mechanism stays the same, but the intent differs completely.
A: They describe the same design. The interface plays the strategy, each implementation plays a concrete strategy, and the method that accepts it plays the context. Java 8 did not replace the pattern, it only shrank each concrete strategy from a whole class to one line.
A: Template Method fixes the algorithm in a parent class and leaves an abstract hook for a subclass, so the variation binds at compile time. Behaviour parameterization passes the variation as an argument, so it binds at runtime and needs no subclass at all.
A: Execute around handles tasks with identical setup and cleanup around a small varying middle, such as opening and closing a file. Your method owns the try block and the close call, while the caller passes only the work that happens in between.
A: A flag explains nothing at the call site, since filter(staff, true) hides its meaning. It also forces you to edit the method for every new requirement, which brings back the duplication you tried to remove.
A: Avoid it when only one variant exists and no second one looks likely. The extra interface and parameter buy flexibility that nobody uses. Wait for the second requirement, then extract the varying part.
A: Everywhere in modern Java. List.removeIf takes a test, List.sort takes a comparison, Map.computeIfAbsent takes a value builder, Optional.orElseGet takes a fallback, and every stream operation takes a block of code.
Let us wrap up what we covered.
One sentence holds the whole article. Keep the part that never changes inside your method, and accept the part that always changes as a parameter.
Write the report tool from section 14 yourself, then swap the rules around. Once that clicks, the rest of Java 8 opens up quickly.
this rules@FunctionalInterface and its checksjava.util.function package