Minimize Surprise in Your Codebase

One of the techniques I came across during my career as a software engineer is to minimize the surprise factor in a code repository. That sounds vague, so this article makes it concrete: what a surprise in code is, what it costs, and how to remove it.

What Surprise Means in Code

Nobody reads a codebase line by line. Developers read a name, a file path or a function signature, and from that they guess what the code does. Most of the time they move on without opening the function. That shortcut is what makes a large codebase workable at all.

A surprise happens when the guess is wrong: the code behaves differently from what the reader intuitively expects. This increases cognitive load, slows down onboarding, and raises the risk of bugs. Surprises come from a few recurring sources:

The expensive surprises are the ones nobody notices. A reader who spots a surprise loses ten minutes. A reader who misses it writes a bug.

Examples of Surprising Code

A Getter That Writes

Here is a function whose name suggests a read-only operation:

def get_user(user_id: int) -> User:
	user = db_session.get_one(User, user_id)
	user.last_seen_at = datetime.now(UTC)
	db_session.commit()
	return user

Tracking when a user was last seen was probably a reasonable feature request. Putting it inside get_user was the quickest place to add it, and it creates three surprises for every caller:

Splitting the read from the write fixes all three:

def get_user(user_id: int) -> User:
	return db_session.get_one(User, user_id)


def record_user_visit(user: User) -> None:
	user.last_seen_at = datetime.now(UTC)

Both names now describe what the functions do. A reader who sees record_user_visit at a call site knows that a write happens there, and the caller decides when to commit.

Two Naming Conventions in One Repository

src/
	profile/
		userProfile.ts
		profile_helpers.ts
	services/
		User_Service.js
		billing-service.js

Each file name is fine on its own. Together they use four conventions, and a reader has to remember which file follows which. Searching for user-service finds nothing. Every import needs a lookup. A new contributor adding a file has no rule to follow, so they invent a fifth convention.

Tests in Arbitrary Folders

app/
	budgets/
		budgets.py
		test_budgets.py
	users/
		users.py
tests/
	test_login.py
scripts/
	check_users.py    # also a test, run by hand before a release

Where are the tests for users.py? They could be in tests/, next to the module, or in a script that one person runs before a release. A developer who changes users.py cannot tell which tests cover it, so they run everything or nothing. The test runner has the same problem: check_users.py does not match its discovery pattern, so it never runs in CI.

Tests next to the module and tests in a top-level tests/ folder are both valid conventions. Using both in one repository is what causes the trouble.

A Hidden Dependency

def send_invoice(invoice: Invoice, mail_client: MailClient) -> None:
	if os.environ.get("DRY_RUN"):
		return

	mail_client.send(to=invoice.customer_email, body=render_invoice(invoice))

The signature says: give me an invoice and a mail client, and I send the invoice. It leaves out that an environment variable can turn the function into a no-op. Someone copies the staging configuration to a new production server, DRY_RUN comes along, and customers stop receiving invoices without a single error in the logs.

The fix is to decide about the dry run where the mail client is created, once, at startup:

def send_invoice(invoice: Invoice, mail_client: MailClient) -> None:
	mail_client.send(to=invoice.customer_email, body=render_invoice(invoice))


# At startup
mail_client = LoggingMailClient() if config.dry_run else SmtpMailClient(config.smtp_url)

The dry run still exists, and it is visible in one place. send_invoice does what its name and signature say. The article on overly broad function parameters covers more ways to make a signature state everything a function depends on.

Why Minimizing Surprise Matters

Software development is primarily about communication. Code instructs a machine, and it also tells the next human what the author intended. The machine ignores names. Humans rely on them.

Minimizing surprise makes a codebase predictable. It is easier to navigate, because files are where you expect them. It is safer to change, because names and signatures tell you what a change affects. And it helps a team grow, because every new contributor spends less time guessing and more time contributing.

One cost is easy to overlook: a single surprise damages trust in every similar piece of code. After a developer finds one get_ function that writes, they can no longer assume that any get_ function in the repository only reads. From then on they open every function before calling it, and the shortcut that made the codebase readable is gone.

Coding agents depend on the same shortcut. An LLM working in your repository guesses from names and conventions just like a new colleague does, and in a surprising codebase it writes code that looks correct and is wrong.

How to Minimize Surprise

1. Use Consistent Naming

Functions, classes and files should follow the same conventions throughout the repository. Which convention you pick matters much less than picking one. snake_case.py is fine, kebab-case.ts is fine, and a folder with both makes every reader stop and check.

Consistency also applies to verbs. When every get_ function reads, every create_ function inserts and every is_ function returns a boolean, a reader can predict a function from its name:

def get_budget(budget_id: int) -> Budget: ...
def create_budget(name: str, owner: User) -> Budget: ...
def delete_budget(budget: Budget) -> None: ...
def is_budget_shared(budget: Budget) -> bool: ...

The same goes for the project structure. Decide where tests, routes and templates live, and apply that decision everywhere.

2. Be Explicit

If a function mutates state, its name should say so. update_user and get_user do different things, and their names make that obvious.

Django has a good example of this: get_or_create(). The name is longer than get(), and in return nobody is surprised when it inserts a row.

Being explicit also means that the signature lists what a function depends on. Everything a function reads from somewhere else (environment variables, global settings, the current time) is a dependency the reader cannot see.

3. Follow the Principle of Least Astonishment

Choose solutions that align with common expectations in your language or framework community. Developers bring these expectations with them from other projects. Every expectation you follow is one they do not need to relearn for your codebase.

Python has a few that are worth knowing:

The last one comes from the standard library. list.sort() sorts in place and returns None, while sorted() returns a new list. Your own functions can follow the same split:

def normalize_tags(tags: list[str]) -> None:
	"""Lowercases the tags in place."""
	tags[:] = [tag.lower() for tag in tags]


def normalized_tags(tags: list[str]) -> list[str]:
	return [tag.lower() for tag in tags]

A caller who writes tags = normalize_tags(tags) gets None and finds the mistake on the next line, which is much better than finding it in production.

Frameworks have expectations too. A Django developer opening your project looks for models.py, views.py and urls.py. Put the code there, and they find their way around on the first day.

4. Document Deviations

Sometimes a convention does not fit. Then break it, and explain why in a comment, right where the reader meets the unusual code.

BudgetFlow has one of these in its database setup. SQLite starts transactions in deferred mode by default, which takes the write lock on the first write. BudgetFlow starts write transactions with BEGIN IMMEDIATE and runs GET requests in read-only mode. Anyone who knows the SQLite defaults would be surprised, so the function explains itself:

def do_begin(conn: Connection) -> None:
	"""
	Write transactions emit BEGIN IMMEDIATE, which takes the write lock up front, so a
	transaction that reads and then writes never fails with SQLITE_BUSY.
	https://kerkour.com/sqlite-for-servers
	https://simonwillison.net/2024/Mar/31/optimizing-sqlite-for-servers

	GET and HEAD requests emit a deferred BEGIN, so they are in parallel under WAL.
	To write in one of them, use `rollback_transaction(begin_write=True)`.
	"""
	...

The docstring answers the two questions a surprised reader has: why does this differ from the default, and what do I do when I need the other behavior? A comment that only repeated what the code does would answer neither.

5. Adopt Linters and Formatters

Code review catches an inconsistency only when the reviewer notices it. Tools check every file on every run. ESLint and Prettier do this for JavaScript and TypeScript, Pylint and Ruff for Python.

A formatter also ends discussions about style, because nobody argues about quote styles once a tool decides them. Linters go further and check naming (Ruff's N rules implement the PEP 8 naming conventions), import order and unused code. A type checker catches yet another kind of surprise: a function that returns None where its signature promises a User.

Finding the Surprises in Your Own Code

The author of a piece of code is the person least able to find its surprises. You know that get_user writes, because you wrote it.

New contributors read the codebase without that knowledge, so their questions show you where the surprises are. Every "wait, why does this..." during onboarding or code review marks a spot where the code did something the reader did not expect. Write those spots down. Each one is a candidate for a rename, a move to a better place, or a comment.

Conclusion

Minimizing surprise is about respecting the mental model of the next person who reads your code, including future you. A predictable codebase lowers the cost of maintenance and makes collaboration smoother.

When in doubt, ask: "If I were new to this repository, would this behavior or structure surprise me?" If the answer is yes, refactor. If there is a good reason to keep it, document that reason where the reader will find it.

Start Your Financial Journey Today

Take control of your financial future with visual planning and dynamic budgeting. Sign up now and start managing your budget with confidence!