Principle of Least Astonishment in API Design

Introduction

This tutorial explains the Principle of Least Astonishment (also called the principle of least surprise) and how to apply it when designing Java APIs - the classes and methods other developers call, including your own teammates. We will start with what the principle says and why surprises turn into bugs. We will then run five real surprises from the JDK itself, to see what an astonishing API looks like. Next, we will look at the naming conventions Java developers rely on, and redesign a small payroll API that breaks them. Lastly, we will list practical guidelines for unsurprising APIs.

What is the Principle of Least Astonishment

The principle says that a component should behave the way most of its users expect it to behave. If a well-informed reader of a method call would be surprised by what it does, the design is at fault, not the reader. The idea has been discussed in programming language and user interface design since at least the 1970s, and Eric S. Raymond's The Art of Unix Programming lists it as the Rule of Least Surprise.

For an API, a reader's expectations come from three places: the name of the method, its signature (parameter and return types), and the conventions they have learned from other APIs - especially the JDK. Astonishment is the gap between what these suggest and what the code really does. Every such gap is a bug waiting for someone who did not read the documentation closely enough.
Principle of least astonishment - the gap between what an API call suggests and what it actually does, shown with List.remove(1)

Example - five surprises in the JDK

Even the JDK, designed with great care, has APIs that regularly surprise experienced developers. Each of the five below is documented, so none is a bug in the JDK - but each has caused countless bugs in programs that use it.
Java code showing five surprising behaviours in JDK APIs
//LeastAstonishment.java
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class LeastAstonishment {

  public static void main(String[] args) {
    // 1. A constructor that takes the double literally
    System.out.println("new BigDecimal(0.1)     = " + new BigDecimal(0.1));
    System.out.println("BigDecimal.valueOf(0.1) = " + BigDecimal.valueOf(0.1));

    // 2. remove(int) versus remove(Object) on a List<Integer>
    List<Integer> grades = new ArrayList<>(List.of(10, 20, 1, 30));
    grades.remove(1);                          // removes the element at index 1
    System.out.println("after remove(1)         = " + grades);
    grades.remove(Integer.valueOf(1));         // removes the value 1
    System.out.println("after remove(value 1)   = " + grades);

    // 3. == on boxed Integers works... until it does not
    Integer a = 127, b = 127, c = 128, d = 128;
    System.out.println("boxed 127 == 127        = " + (a == b));
    System.out.println("boxed 128 == 128        = " + (c == d));

    // 4. split() silently drops trailing empty strings
    String[] fields = "Tom Jones,Finance,,".split(",");
    System.out.println("fields in 4-column row  = " + fields.length);

    // 5. Arrays.asList: fixed size, and writes through to the array
    String[] names = {"Tom Jones", "Harry Major"};
    List<String> list = Arrays.asList(names);
    list.set(0, "Nancy Smith");
    System.out.println("array after list.set()  = " + Arrays.toString(names));
    try {
      list.add("Ethan Hardy");
    } catch (UnsupportedOperationException e) {
      System.out.println("list.add(...)           = UnsupportedOperationException");
    }
  }
}
 OUTPUT of the above code
new BigDecimal(0.1)     = 0.1000000000000000055511151231257827021181583404541015625
BigDecimal.valueOf(0.1) = 0.1
after remove(1)         = [10, 1, 30]
after remove(value 1)   = [10, 30]
boxed 127 == 127        = true
boxed 128 == 128        = false
fields in 4-column row  = 2
array after list.set()  = [Nancy Smith, Harry Major]
list.add(...)           = UnsupportedOperationException

Explanation of the code

  • BigDecimal(double): the constructor faithfully converts the binary double closest to 0.1, which is not 0.1. Anyone using BigDecimal wants exact decimals, so this is the opposite of what they came for. BigDecimal.valueOf(0.1) uses the double's string form and gives 0.1.
  • List.remove(1): List has both remove(int index) and remove(Object value). On a List<Integer>, an int argument picks the index version, so the value 20 was removed, not the value 1. Two overloads with different meanings and compatible parameters are a trap.
  • Boxed == : == compares references. Small Integer values (by default -128 to 127) are cached and shared, so the comparison happens to work for 127 and fails for 128. Tests with small numbers pass; production with larger ones fails.
  • split(): a row with four columns, the last two empty, produced only two fields, because split(",") removes trailing empty strings. split(",", -1) keeps them.
  • Arrays.asList(): the name follows the asX() convention, meaning a view - and it is one: setting an element changed the array. But it returns a List, and nothing in the type says it cannot grow, so add() throws at runtime. The same problem appears with List.of(), as discussed in the SOLID principles overviewClick to read tutorial: SOLID Principles in Java: Overview with Examples.

Conventions are part of the contract

Java developers read a method name and immediately assume things about it. These conventions are not enforced by the compiler, but they are as real as the method signature - the JDK and most libraries follow them, so readers rely on them without checking.
Java method naming conventions and what they promise - get, is, set, of, valueOf, to, as, with, plus, new and create

Redesigning a surprising payroll API

Here is a small payroll API with several surprises, followed by a version without them -
A payroll API full of surprises
public class Payroll {
  // Returns the internal list: callers who sort or filter it change the payroll
  public List<Employee> getEmployees() { return employees; }

  // A "query" that also marks the bonus as paid - calling it twice loses the bonus
  public boolean isBonusDue(Employee e) { ... bonusPaid.add(e.id()); ... }

  // Is the timeout in seconds or milliseconds? What do true and false mean?
  public Payslip calculate(Employee e, int timeout, boolean b1, boolean b2) { ... }

  // Returns null when there is no payslip, but an empty list when there are none
  public Payslip findPayslip(String id) { ... }
  public List<Payslip> findPayslips(String employeeId) { ... }
}
The same API without the surprises
public class Payroll {
  // An unmodifiable copy: callers cannot change the payroll by accident
  public List<Employee> employees() { return List.copyOf(employees); }

  // A query and a command, each doing what its name says
  public boolean isBonusDue(Employee e) { ... }
  public void markBonusPaid(Employee e) { ... }

  // Units in the type, options named
  public Payslip calculate(Employee e, Duration timeout, PayslipOptions options) { ... }

  // Absence is explicit and consistent
  public Optional<Payslip> findPayslip(PayslipId id) { ... }
  public List<Payslip> findPayslips(EmployeeId employeeId) { ... }
}

Guidelines for unsurprising APIs

  • Follow the conventions in the table above, and the patterns of the JDK. A familiar shape needs no documentation.
  • Be consistent: the same parameter order, the same names for the same concept, and the same way of reporting absence and errors across the whole API.
  • No hidden side effects: getters, is methods, equals(), hashCode() and toString() should never change state or do expensive work such as I/O.
  • Avoid overloads that can be confused: if remove(int) and remove(Object) mean different things, give them different names.
  • Put units and meaning in types: Duration, BigDecimal for money, enums instead of int codes, records instead of loose String ids.
  • Fail loudly and early on invalid input, with a message that says what was wrong - see Fail Fast and Defensive ProgrammingClick to read tutorial: Fail Fast and Defensive Programming in Java.
  • Choose safe defaults: the shortest call should do the common, safe thing. Dangerous options should need an explicit choice.
  • Keep subtypes honest: an implementation that throws where its interface promises an operation surprises every caller - see the Liskov Substitution PrincipleClick to read: Liskov Substitution Principle.

Summary

In this tutorial we first saw that the Principle of Least Astonishment asks components to behave as their users expect, and that for an API those expectations come from names, signatures and conventions. Next we ran five documented but surprising behaviours of the JDK - BigDecimal(double), List.remove(int), boxed ==, split() and Arrays.asList(). We then looked at the naming conventions Java developers rely on, and redesigned a payroll API to remove a leaking getter, a query with a side effect, anonymous flags and inconsistent handling of missing results. Lastly, we listed practical guidelines for designing unsurprising APIs.