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 -
  1. 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.
  2. 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.
Pure function versus impure function - a pure function depends only on its inputs while an impure one reads and writes hidden state such as globals, the clock and the console

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)));
  }
}
 OUTPUT of the above code
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, leaving staff untouched. 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 takes today as 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.
Functional core imperative shell - pure functions make the decisions while a thin outer shell reads input and writes output
In the payroll example, the shell reads the staff list from the database and today's date from the clock, passes them to 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 to map(), 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

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.