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.

  1. Choose Honest Names: Does the name of your function accurately and completely describe what it does, including any side effects?
  2. Be Consistent: Follow the established conventions of your language, framework, and your own codebase. If get_by_id returns None, then all get_by_id methods should return None.
  3. Encapsulate Complexity: Hide implementation details, but don’t hide surprising behavior. The interface should be simple, but not deceptive.
  4. Follow Command-Query Separation (CQS): Functions that change state (Commands) should be separate from functions that return data (Queries).
  5. 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.