Pure Functions and Side Effects with Java Examples
Introduction
This tutorial explains pure functions and side effects, and how to use them as a design principle in Java. We will start with the two conditions that make a function pure, and the kinds of side effects that make one impure. We will then compare a pure and an impure version of a salary raise and a length-of-service calculation, and see the impure raise give a different answer when it is called twice. Next, we will look at why pure functions are easier to test, cache and run in parallel, and at the "functional core, imperative shell" way of structuring a program. Lastly, we will see where purity matters in everyday Java - in stream lambdas - and where side effects are unavoidable.What is a pure function
A function is pure when both of these hold -- Its result depends only on its arguments. Called with the same arguments, it always returns the same result. It does not read the clock, a random number generator, a global variable, a file or a database.
- It has no side effects. Its only effect is the value it returns. It does not change its arguments, fields, global state, or anything outside the program, such as the console, a file or the network.
Math.max(a, b), String.strip() and LocalDate.plusDays(n) are pure. LocalDate.now(), Collections.sort(list) and System.out.println() are not. A call to a pure function can be replaced by its result without changing the program - a property called referential transparency.
Example - pure and impure versions side by side
The code below gives employees a raise, and calculates length of service, in two styles. The impure versions read a global rate, change the list they are given, and read the clock. The pure versions take everything they need as arguments and return new values. To keep the numbers exact, salaries are whole amounts and the raise is a whole percentage.Java code comparing impure and pure functions for a salary raise and length of service
//PureFunctions.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
public class PureFunctions {
record Employee(String name, long salary) {}
// ---------- Impure ----------
static int raisePercent = 5; // global and mutable
static void applyRaiseInPlace(List<Employee> staff) {
for (int i = 0; i < staff.size(); i++) { // changes the caller's list
Employee e = staff.get(i);
staff.set(i, new Employee(e.name(), e.salary() * (100 + raisePercent) / 100));
}
}
static long daysOfServiceToday(LocalDate joined) {
return ChronoUnit.DAYS.between(joined, LocalDate.now()); // reads the clock
}
// ---------- Pure ----------
static List<Employee> withRaise(List<Employee> staff, int percent) {
return staff.stream()
.map(e -> new Employee(e.name(), e.salary() * (100 + percent) / 100))
.toList();
}
static long daysOfService(LocalDate joined, LocalDate today) {
return ChronoUnit.DAYS.between(joined, today);
}
public static void main(String[] args) {
List<Employee> staff = List.of(new Employee("Tom Jones", 5000),
new Employee("Nancy Smith", 6000));
List<Employee> raised = withRaise(staff, 5);
System.out.println("Pure - original : " + staff);
System.out.println("Pure - raised : " + raised);
System.out.println("Pure - called again, same result? "
+ withRaise(staff, 5).equals(raised));
List<Employee> payroll = new ArrayList<>(staff);
applyRaiseInPlace(payroll);
applyRaiseInPlace(payroll); // e.g. a retry after a timeout
System.out.println("Impure - after two calls : " + payroll);
raisePercent = 10; // some other code changes the global
List<Employee> another = new ArrayList<>(staff);
applyRaiseInPlace(another);
System.out.println("Impure - after global change : " + another);
LocalDate joined = LocalDate.of(2020, 3, 1);
System.out.println("Pure - days of service on 2026-03-01: "
+ daysOfService(joined, LocalDate.of(2026, 3, 1)));
}
}
Pure - original : [Employee[name=Tom Jones, salary=5000], Employee[name=Nancy Smith, salary=6000]] Pure - raised : [Employee[name=Tom Jones, salary=5250], Employee[name=Nancy Smith, salary=6300]] Pure - called again, same result? true Impure - after two calls : [Employee[name=Tom Jones, salary=5512], Employee[name=Nancy Smith, salary=6615]] Impure - after global change : [Employee[name=Tom Jones, salary=5500], Employee[name=Nancy Smith, salary=6600]] Pure - days of service on 2026-03-01: 2191
Explanation of the code
withRaise()builds and returns a new list, leavingstaffuntouched. Calling it again with the same arguments gives an equal result, so it is safe to call twice, to cache its result, or not to call it at all if the result is not needed.applyRaiseInPlace()changes the list it is given. Calling it twice - for example when a timed-out request is retried - gives two raises: Tom's 5000 became 5512 instead of 5250.- Its result also depends on
raisePercent, a hidden input. When unrelated code set it to 10, the same call with the same list gave a different answer. Nothing in the method's signature warns about either effect. daysOfServiceToday()returns a different answer every day, so a unit test of it cannot assert a fixed number. Its pure twin takestodayas an argument, so the test simply passes a date - which is why the program's output for it is the same whenever it runs.- The impure versions are not wrong in themselves - something has to read the clock and update the payroll. The point is to keep those effects in a few known places, and the calculations pure.
Benefits of pure functions
- Easy to test: give inputs, check the output. No mocks, no set-up of global state, no clean-up afterwards.
- Easy to reason about: everything that affects the result is in the parameter list, and nothing else in the program changes when you call it.
- Safe to cache: the result for given arguments never changes, so it can be stored and reused (memoization).
- Safe in parallel: pure functions share no mutable state, so they can run on many threads at once with no locking - which is what parallel streams rely on.
- Safe to retry and reorder: calling a pure function twice, or calling two of them in a different order, cannot change the outcome.
Functional core, imperative shell
A useful program must have side effects: it reads input, saves data and shows results. The design question is where. A popular answer, named by Gary Bernhardt, is functional core, imperative shell: put all the decisions in a core of pure functions, and keep a thin outer shell that does the I/O - read, call the core, write.
withRaise() and daysOfService(), and saves what comes back. The core can be tested exhaustively with plain values, and the shell has so little logic that a few integration tests cover it. This is the same idea as Separation of ConcernsClick to read tutorial: Separation of Concerns Explained with Java Examples, applied to effects.
Pure functions in Java streams
Pure functions matter most in Java when you write lambdas for streams. The Streams APIClick to read: Streams API documentation asks for behavioural parameters - the functions passed tomap(), filter() and the rest - to be non-interfering and, in most cases, stateless. A lambda with side effects can give wrong results, especially in a parallel stream -
A lambda with a side effect versus a pure pipeline
// Side effect: adds to an outside list from inside the stream - not thread-safe
List<String> names = new ArrayList<>();
staff.parallelStream().forEach(e -> names.add(e.name())); // may lose elements or throw
// Pure: let the stream build the result
List<String> names = staff.parallelStream().map(Employee::name).toList();
forEach() is meant for the final effect at the end of a pipeline, such as printing - not for building results. See also the imperative versus functional programmingClick to read: imperative versus functional programming article for the wider picture.
Practical guidelines
- Pass in what changes: take
today, aClock, or a random seed as a parameter instead of reading them inside a calculation. - Return new values instead of changing arguments, using records and immutable objectsClick to read tutorial: Immutability in Java: Principles and Practice.
- Keep queries free of side effects, as Command-Query SeparationClick to read tutorial: Command-Query Separation (CQS) with Java Examples asks.
- Avoid mutable static fields: they are hidden inputs to every method that reads them.
- Do not chase purity everywhere: logging, metrics and caching are side effects that are usually harmless. Aim for pure business logic, not for a program without effects.
Summary
In this tutorial we first saw the two conditions for a pure function - its result depends only on its arguments, and it has no side effects - and the idea of referential transparency. Next we compared pure and impure versions of a raise and a length-of-service calculation: the impure raise doubled when retried and changed when a global was changed, and the clock-reading version could not be tested with a fixed answer. We then listed the benefits of pure functions for testing, reasoning, caching, parallelism and retries. We then saw how the functional core, imperative shell structure keeps the effects at the edges. Lastly, we saw why stream lambdas should be pure, and some practical guidelines.Programming Principles Tutorials
Command-Query Separation (CQS) with Java ExamplesClick to read tutorial: Command-Query Separation (CQS) with Java Examples Fail Fast and Defensive Programming in JavaClick to read tutorial: Fail Fast and Defensive Programming in Java SOLID Principles in Java: Overview with ExamplesClick to read tutorial: SOLID Principles in Java: Overview with Examples Composition over Inheritance in Java with ExamplesClick to read tutorial: Composition over Inheritance in Java with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC
Command-Query Separation (CQS) with Java ExamplesClick to read tutorial: Command-Query Separation (CQS) with Java Examples Fail Fast and Defensive Programming in JavaClick to read tutorial: Fail Fast and Defensive Programming in Java SOLID Principles in Java: Overview with ExamplesClick to read tutorial: SOLID Principles in Java: Overview with Examples Composition over Inheritance in Java with ExamplesClick to read tutorial: Composition over Inheritance in Java with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC