The best code is the code you barely notice. It does exactly what it claims, no more, no less. No clever trick. No flexible abstraction waiting for a use case that never arrived. No layer of indirection because someone, someday, might want to swap the implementation.

This is harder than it sounds. Engineering culture rewards cleverness. A clever solution feels good to write, gets compliments in review, and shows you are thinking deeply. A simple solution feels almost embarrassing — as if you should have done more.

You should not have done more. Simplicity is the harder discipline.

Cleverness is a tax on every future reader#

Every clever piece of code is a tax on the next person who reads it. They have to load your trick into their head before they can understand what the code does. They have to figure out which of the abstraction’s many uses applies here. They have to verify that the flexibility you built actually does what you claimed.

A clever solution that saves one person an hour of writing costs ten people an hour each of reading. The arithmetic almost always favors the boring solution.

Complexity also compounds. A clever piece of code attracts more cleverness — additional abstraction to handle additional cases, edge handling for problems that the original cleverness created. The codebase grows in complexity faster than it grows in capability, and eventually it grows fast enough that no one can change anything safely.

Optimize for reading, not writing#

Code is written once and read many times. The ratio is at least 10:1, often 100:1. Optimize for the reader.

If a one-line clever expression can be replaced by three obvious lines, write three obvious lines. The performance is identical. The cognitive cost is much lower.

Prefer the obvious over the elegant#

Elegance is in the eye of the writer. Obviousness is in the eye of the reader. When they conflict, obviousness wins.

A switch statement with ten cases is usually clearer than a metaprogramming trick that handles all of them in three lines. The trick is satisfying to write and miserable to debug.

Show, don’t hide#

Resist the urge to hide work behind decorators, dynamic dispatch, or framework magic. The next reader will have to find what is hidden and where, and then keep that location in their head while they reason about the code.

Inline beats indirected when the indirection is not earning its keep.

YAGNI ruthlessly#

The most common shape of unnecessary complexity is the abstraction built for a future that does not come. “We might need to support multiple databases” turns into a generic database layer that adds friction to every query — and the second database never gets added. “We might want different rendering strategies” turns into a strategy pattern with one implementation that has been in place for four years.

YAGNI — You Aren’t Gonna Need It — is the most reliable predictor. The future use case you are designing for, statistically, will not appear. Or it will appear in a shape your abstraction does not fit. Either way, you have paid for flexibility that gives you nothing.

Build for the case you have#

When you have one caller, build for one caller. When you have one backend, build for one backend. When you have one user type, build for one user type.

The temptation is to anticipate the second case. Don’t. Anticipated second cases almost never arrive. The ones that do arrive are usually shaped differently than you anticipated, and your generic code makes the real second case harder, not easier.

Wait for the second case#

Wait for the second concrete caller before you extract the shared abstraction. Refactoring two real implementations into one is easy. Refactoring a hypothetical abstraction to fit a real case is hard.

The rule of three is a useful heuristic: extract the abstraction on the third occurrence, not the first or second. By the third, you have enough information to see what is genuinely shared and what is incidental.

Configuration is not flexibility#

Adding a config option is not the same as adding the right abstraction. Every configuration option is a permanent commitment — someone is now relying on it, and you cannot remove it without breaking them.

Before you add a flag, ask: who is asking for this? If the answer is “no one, but they might”, do not add it.

Delete unused code#

When code stops being used, delete it. Do not leave it commented out. Do not leave it behind a flag “in case we need it”. The version control system remembers; you do not need to.

Dead code looks alive to the next reader. They have to figure out it is dead before they can ignore it.

What simplicity looks like#

Simple code says what it does. Simple architecture has fewer moving pieces. Simple processes have fewer steps.

Names that match behavior#

A function called processUser should process a user — not just users, not also accounts, not sometimes a team. If the function does more than its name says, either rename the function or split it.

The function name is a contract with every future caller. Honor it.

Linear control flow#

Code that reads top to bottom is easier to follow than code that jumps around. Resist early returns inside deeply nested conditionals. Resist callbacks where a loop would do. Resist async where sync would do.

When you must branch, branch shallowly. A function with one if-else is fine. A function with five nested ifs is a function that should be split.

No hidden state#

Mutations that happen far from where the variable was declared are footguns. Module-level globals that change at runtime are footguns. Functions that look pure but secretly read or write external state are footguns.

If the behavior of a function depends on something not in its arguments, that dependency should be visible — in the signature, in the type, or at least in the doc comment immediately above.

One way to do each thing#

A codebase with three different HTTP clients is a codebase where every new feature spawns a meeting about which one to use. Pick one. Migrate the rest. Resist the urge to introduce a fourth.

The same applies to logging, error handling, configuration, deployment, and naming. Consistency is more valuable than the local optimum.

Architectural simplicity#

Code-level simplicity is necessary but not sufficient. The system architecture has to be simple too.

Fewer moving pieces#

Every service, every queue, every cache, every database is a thing that can break. The system’s reliability is roughly the product of the reliability of its components. Adding components multiplies the failure modes.

Before you add a new component, ask: can I solve this without it? A larger Postgres beats a new Redis. A cron job beats a new orchestration system. A library beats a new service.

Boring tech is good tech#

Use the database your team already operates. Use the language your team already writes. Use the deployment system that already works. Boring technology has been used by thousands of teams before yours; the bugs are known, the documentation is plentiful, and the operational playbook is established.

New technology is interesting to evaluate and expensive to operate. Reserve novelty for the parts of your system where it actually moves the needle.

Synchronous beats asynchronous#

When two systems talk to each other, the simplest pattern is a synchronous request and response. Add async only when sync genuinely does not work — when latency matters, when the receiver is unreliable, when the volume exceeds what sync can handle.

Async systems require queues, retries, ordering guarantees, idempotency, dead-letter handling, and operational monitoring. Each of those is a problem the sync version did not have.

One database, until you can’t#

Multiple databases mean multiple sources of truth, distributed transactions, eventual consistency, and operational overhead. A single database, with judicious use of schemas and indexes, handles much more load than people assume.

Split when you have a real reason to split — different scaling characteristics, different access patterns, different teams owning different domains. Not because microservices are fashionable.

When to add complexity#

Simplicity is not a moral commandment. Sometimes the simple version genuinely is not enough.

When the simple version measurably fails#

If a query is too slow, add an index. If an index is not enough, add a cache. If a cache is not enough, redesign the access pattern. Add each layer only when the previous one demonstrably stops working.

Most performance complexity is added speculatively, against a problem that never appears. Wait for the actual problem.

When the complexity pays rent#

Every piece of complexity should justify its existence. If you cannot point to a concrete benefit it provides — a specific bug it prevents, a specific feature it enables, a specific scale it supports — it is not earning its rent.

When complexity stops earning its rent, remove it. Code does not get to stay forever just because it was hard to write.

When you can buy it#

Sometimes complexity is unavoidable, but you can buy it instead of building it. A managed database is more complex internally than yours; you do not have to operate that complexity. An off-the-shelf auth provider is more complex than yours; you do not have to maintain it.

Buy the complex thing if it is core to your business; do not buy boring infrastructure you could build in a week.

The discipline#

The hardest part is resisting the urge to be impressive. Most engineering decisions are made under no real pressure to be complex. The complexity creeps in because the engineer wants to be interesting, or wants to anticipate a future, or wants to use the pattern they just learned.

Ask, every time: what is the simplest thing that could work? Build that. When it stops being enough, change it. Until then, the simple version is doing its job — including the job of being easy to change when the time comes.

Good engineering is full of code that looks like nothing happened. That is the point.