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.
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");
}
}
}
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
doubleclosest to 0.1, which is not 0.1. Anyone usingBigDecimalwants 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):
Listhas bothremove(int index)andremove(Object value). On aList<Integer>, anintargument 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. SmallIntegervalues (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 aList, and nothing in the type says it cannot grow, soadd()throws at runtime. The same problem appears withList.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.
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) { ... }
}
employees()returns a copy. A getter that hands out a live internal collection is one of the most common surprises in Java code - see Immutability in JavaClick to read tutorial: Immutability in Java: Principles and Practice.isBonusDue()no longer changes anything. A method named like a question must not have side effects - this is Command-Query SeparationClick to read tutorial: Command-Query Separation (CQS) with Java Examples.Durationcarries its unit, soDuration.ofSeconds(30)cannot be confused with 30 milliseconds. An options object replaces two anonymousbooleanflags that no reader of the call can decode.Optionalsays in the signature that a payslip may be missing, and both methods now treat "nothing found" the same way. Types such asPayslipIdstop an employee id from being passed where a payslip id is expected.
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,
ismethods,equals(),hashCode()andtoString()should never change state or do expensive work such as I/O. - Avoid overloads that can be confused: if
remove(int)andremove(Object)mean different things, give them different names. - Put units and meaning in types:
Duration,BigDecimalfor money, enums instead ofintcodes, records instead of looseStringids. - 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.Programming Principles Tutorials
Design by Contract in Java with ExamplesClick to read tutorial: Design by Contract in Java with Examples 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 Law of Demeter in Java Explained with ExamplesClick to read tutorial: Law of Demeter in Java Explained with Examples
Design by Contract in Java with ExamplesClick to read tutorial: Design by Contract in Java with Examples 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 Law of Demeter in Java Explained with ExamplesClick to read tutorial: Law of Demeter in Java Explained with Examples