Principle of Least Astonishment: Write Predictable Code
The Principle of Least Astonishment (or Surprise) states that the result of an operation should be obvious, consistent, and predictable. Learn why surprising code leads to bugs.
Have you ever used a library or a function and had it behave in a way you did not expect at all? A function named getUser() that also deletes the user’s cache? A button that says “Save” but actually discards your changes? These are violations of the Principle of Least Astonishment (POLA), also known as the Principle of Least Surprise.
The principle states that a component of a system should behave in a way that most users would expect it to behave. The behavior should not astonish or surprise users. When a developer uses your function, class, or API, their first intuitive guess about how it works should be correct.
Surprising code is a recipe for bugs. If a developer makes a reasonable assumption about your code that turns out to be false, they will misuse it.
Where Does Surprise Come From?
Violations of POLA often stem from a mismatch between a component’s name and its actual behavior.
1. Functions with Hidden Side Effects
This is the most common violation. A function should do what its name says it does, and nothing more. A function that retrieves data should not secretly change the state of the system.
Before: A Surprising Function
class UserCache {
private cache: Map<string, User> = new Map();
// This name is a lie. It doesn't just "get" the user.
getUser(id: string): User | undefined {
if (this.cache.has(id)) {
const user = this.cache.get(id);
// SURPRISE! This "get" function also has a side effect.
this.cache.delete(id);
return user;
}
return undefined;
}
}
// A developer might call this thinking it's a safe, read-only operation.
const user = userCache.getUser("123");
const userAgain = userCache.getUser("123"); // Returns undefined! Astonishing!
After: Predictable and Honest Functions The solution is to separate the command (changing state) from the query (reading state). This is the Command-Query Separation principle, which is a direct application of POLA.
class UserCache {
private cache: Map<string, User> = new Map();
// A "query" method. It only reads data.
peekUser(id: string): User | undefined {
return this.cache.get(id);
}
// A "command" method. Its name clearly states it will change state.
popUser(id: string): User | undefined {
const user = this.cache.get(id);
this.cache.delete(id);
return user;
}
}
// Now the developer's intent is clear.
const user = userCache.popUser("123"); // I expect the user to be removed.
const userAgain = userCache.peekUser("123"); // I expect to see the current state.
2. Inconsistent Conventions
When similar things are not treated in similar ways, it creates surprise.
Before: Inconsistent API
class ApiClient:
def get_user_by_id(self, user_id: int) -> dict:
# Returns the user dictionary or raises NotFoundError
# ...
def get_product_by_id(self, product_id: int) -> dict | None:
# Returns the product dictionary or None if not found
# ...
A developer using this client has to remember which function raises an exception and which one returns None. This inconsistency is surprising and will inevitably lead to bugs when someone forgets to wrap get_user_by_id in a try...except block.
After: Consistent API Pick one convention and stick to it.
class ApiClient:
def get_user_by_id(self, user_id: int) -> dict | None:
# Convention: All "get" methods return None on failure.
# ...
def get_product_by_id(self, product_id: int) -> dict | None:
# ...
3. Violating Standard Idioms and Data Types
Users expect standard data types and language features to behave in a certain way. Don’t subvert these expectations.
Before: A Deceptive Getter
class ShoppingCart {
private _items: Item[] = [];
// This looks like a simple property getter, but it's doing work.
get items(): Item[] {
// SURPRISE! Accessing '.items' triggers a database call every time.
return database.fetchItemsForCart(this.id);
}
}
const cart = new ShoppingCart();
// Developer thinks this is a cheap property access and might do it in a loop.
for (let i = 0; i < cart.items.length; i++) {
// This runs a DB query on every iteration! A huge, surprising performance bug.
}
After: An Explicit Method
If an operation is expensive (like a network or database call), it should be a method, not a property getter. The () signals that work is being done.
class ShoppingCart {
private _items: Item[] = [];
// The name clearly implies an action that might be expensive.
public async getItemsFromDb(): Promise<Item[]> {
this._items = await database.fetchItemsForCart(this.id);
return this._items;
}
}
const cart = new ShoppingCart();
// The 'async' and '()' make it clear this is not a simple property access.
const items = await cart.getItemsFromDb();
for (let i = 0; i < items.length; i++) {
// Now we are iterating over a local copy. No surprise.
}
How to Apply the Principle of Least Astonishment
When designing a class or a function, put yourself in the shoes of the developer who will use it.
- Choose Honest Names: Does the name of your function accurately and completely describe what it does, including any side effects?
- Be Consistent: Follow the established conventions of your language, framework, and your own codebase. If
get_by_idreturnsNone, then allget_by_idmethods should returnNone. - Encapsulate Complexity: Hide implementation details, but don’t hide surprising behavior. The interface should be simple, but not deceptive.
- Follow Command-Query Separation (CQS): Functions that change state (Commands) should be separate from functions that return data (Queries).
- Don’t Surprise with Performance: If an operation is much more expensive than it looks, make that clear in its name or signature (e.g., by making it an async method).
Writing predictable code is about empathy. It’s about respecting the mental model of the next person who has to read or use your code. By minimizing surprise, you create systems that are easier to reason about, less prone to bugs, and ultimately more maintainable.