Fail Fast and Defensive Programming in Java

Introduction

This tutorial explains two related principles for handling bad data and bugs - fail fast and defensive programming - with Java examples. We will start with what failing fast means and why an error that is noticed late costs so much more. We will then run an example in which bad data is quietly accepted and crashes a report later, and compare it with a class that rejects the same data at once with a clear message. We will also see Java's own fail-fast iterators. Next, we will look at defensive programming, where it helps and where it hides bugs, and how to combine the two. Lastly, we will list the Java tools for failing fast.

What does fail fast mean

A fail-fast system reports a problem the moment it detects one, and stops the operation, instead of carrying on with data it knows, or could know, is wrong. Jim Shore described the idea in his 2004 IEEE Software column Fail Fast: the most annoying bugs are the ones that show up long after their cause, so code should check its assumptions early and complain loudly.

The cost of an error grows with the distance it travels. A missing department noticed while importing a file costs one log message. The same missing department noticed weeks later, when a report crashes, costs a debugging session, and a clean-up of everything that was built on the bad record in the meantime.
Fail fast versus fail late - an error caught in the Employee constructor at import time versus a NullPointerException in a report weeks later

Example - failing late and failing fast

The program below imports two employees, one of whom has no department, in two ways. LenientEmployee accepts whatever it is given, and the problem only appears when a report later uses the department. The Employee record checks its data in its constructor. The last part shows a fail-fast feature built into the JDK's collections.
Java code comparing a lenient class that fails late with a class that fails fast
//FailFastExample.java
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Objects;

public class FailFastExample {

  // ---------- Fails late: accepts anything ----------
  static class LenientEmployee {
    final String name;
    final String department;
    final double salary;

    LenientEmployee(String name, String department, double salary) {
      this.name = name;
      this.department = department;
      this.salary = salary;
    }
  }

  static String departmentReport(List<LenientEmployee> staff) {
    StringBuilder report = new StringBuilder();
    for (LenientEmployee e : staff) {
      report.append(e.department.toUpperCase()).append(": ").append(e.name).append('\n');
    }
    return report.toString();
  }

  // ---------- Fails fast: rejects bad data at construction ----------
  record Employee(String name, String department, double salary) {
    Employee {
      Objects.requireNonNull(name, "name is required");
      Objects.requireNonNull(department, "department is required for " + name);
      if (salary <= 0) {
        throw new IllegalArgumentException(
            "salary must be positive for " + name + ": " + salary);
      }
    }
  }

  public static void main(String[] args) {
    List<LenientEmployee> imported = new ArrayList<>();
    imported.add(new LenientEmployee("Tom Jones", "Finance", 5200));
    imported.add(new LenientEmployee("Harry Major", null, 4100));
    System.out.println("Lenient import: " + imported.size() + " employees accepted");
    try {
      System.out.print(departmentReport(imported));
    } catch (NullPointerException e) {
      System.out.println("Report crashed: " + e.getMessage());
    }

    try {
      new Employee("Harry Major", null, 4100);
    } catch (NullPointerException e) {
      System.out.println("Fail-fast import rejected: " + e.getMessage());
    }
    try {
      new Employee("Ethan Hardy", "Sales", -50);
    } catch (IllegalArgumentException e) {
      System.out.println("Fail-fast import rejected: " + e.getMessage());
    }

    List<String> names = new ArrayList<>(List.of("Tom Jones", "Harry Major", "Ethan Hardy"));
    try {
      for (String n : names) {
        if (n.startsWith("Tom")) names.remove(n);      // modifies the list while iterating
      }
    } catch (ConcurrentModificationException e) {
      System.out.println("Iterator failed fast: " + e.getClass().getSimpleName());
    }
    names.removeIf(n -> n.startsWith("Harry"));        // the safe way to remove while scanning
    System.out.println("After removeIf: " + names);
  }
}
 OUTPUT of the above code
Lenient import: 2 employees accepted
Report crashed: Cannot invoke "String.toUpperCase()" because "e.department" is null
Fail-fast import rejected: department is required for Harry Major
Fail-fast import rejected: salary must be positive for Ethan Hardy: -50.0
Iterator failed fast: ConcurrentModificationException
After removeIf: [Ethan Hardy]

Explanation of the code

  • The lenient import accepted both employees. The problem surfaced only inside departmentReport(), and the exception message describes the report's code, not the import that let a missing department in. In a real system the report would run days later, and the bad record would already be in the database.
  • The fail-fast Employee refused the same data in its compact constructor. The messages name the field, the employee and the bad value, at the moment the data arrives - while the file and line that caused it are still known.
  • Because an Employee can only be created with valid data, every method that receives one can rely on it. The report does not need its own null checks.
  • The last part shows Java's fail-fast iterators. Removing an element from an ArrayList while a for-each loop is iterating over it makes the iterator throw ConcurrentModificationException on its next step, rather than skipping or repeating elements silently. removeIf() is the safe way to remove while scanning.
  • Note that the fail-fast iterator is a best-effort check. Removing the second-to-last element, for example, can end the loop before the iterator notices. It is a safety net for bugs, not a guarantee to rely on.

What is defensive programming

Defensive programming means writing code that keeps working correctly even when it is used in ways its author did not intend: it checks its inputs, protects its internal state, and copes with failures in the things it depends on. Typical defensive techniques are -
  • Validating inputs to public methods and constructors.
  • Defensive copies of mutable arguments and return values, so callers cannot change an object's state from outside - see Immutability in JavaClick to read tutorial: Immutability in Java: Principles and Practice.
  • Handling failures of external systems: timeouts on network calls, retries with limits, and clear errors when a file is missing.
  • Not trusting data from outside: user input, HTTP requests, files and other services are checked before use.

When defensive programming goes wrong

Defensive programming turns harmful when "defend" comes to mean "hide the problem and carry on" -
Defensive code that hides bugs
// Swallowing the exception: the payroll run "succeeds" with a missing payslip
try {
  sendPayslip(employee);
} catch (Exception e) {
  // ignore
}

// Inventing a default: a missing salary silently becomes 0
double salary = (record.salary() == null) ? 0 : record.salary();

// Checking the same thing everywhere: noise that hides the real logic
if (employee != null && employee.department() != null && !employee.name().isEmpty()) { ... }
Each of these turns a loud, early failure into a quiet, late one - the opposite of failing fast. An ignored exception means a missing payslip that no one knows about. A default salary of 0 means an employee who is paid nothing. Repeated null checks deep inside the program mean no one knows where the data is actually guaranteed to be valid. See Java exception handling best practicesClick to read: Java exception handling best practices for how to handle exceptions properly.

Combining the two - defend the boundary, fail fast inside

The two principles fit together when you draw a trust boundary around your core code. Everything outside it - users, files, HTTP requests, other services - is checked defensively, and turned into valid domain objects or rejected with a clear error. Inside, code trusts the types it receives and fails fast, with an exception or an assertion, if an assumption is broken - because inside the boundary, a broken assumption means a bug.
Defensive programming at the trust boundary and fail fast inside - raw input is validated at the edge and the core trusts its types
This way each value is checked once, where it enters, and the rest of the code is free of repeated checks. Types such as the EmployeeId record in DRY, KISS and YAGNIClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples carry the proof of validity with them.

Java tools for failing fast

  • Objects.requireNonNull(value, message): rejects null with your message. Use it on constructor and method parameters.
  • Objects.checkIndex(index, length): rejects an out-of-range index (Java 9 and later).
  • IllegalArgumentException and IllegalStateException: the standard exceptions for a bad argument and for a call made at the wrong time.
  • Compact constructors in records: one place to validate every way an object can be created.
  • List.copyOf(), List.of() and friends: reject null elements, so a bad element is caught when the list is built, not when it is read.
  • assert: checks internal assumptions during development and testing. Assertions are disabled by default and switched on with java -ea, so never use them to validate input from outside.
  • Helpful NullPointerException messages: since Java 14, an NPE names the expression that was null - see Java 14 Helpful NullPointerExceptionsClick to read: Java 14 Helpful NullPointerExceptions. They help when something does slip through, but a check at the boundary gives a better message, earlier.

Summary

In this tutorial we first saw what failing fast means and why the cost of an error grows with the distance it travels. Next we ran an example in which a lenient class accepted a missing department and a report crashed later, while a record with a validating constructor rejected the same data at once with a clear message - and saw Java's fail-fast iterators. We then looked at defensive programming and its useful techniques, and at the ways it goes wrong by swallowing exceptions, inventing defaults and repeating checks. We then combined the two: defend the trust boundary, and fail fast inside it. Lastly, we listed the Java tools for failing fast.