Dave’s tenets of software engineering
Here are some tenets of software engineering that I’ve gathered through the years. I’ll probably add to this list over time as I think of more.
Above all, empathy
All software that stand the test of time come from a place of empathy: it serves some human being well in one form or another. Your best code arises when you have a picture of its user in mind; it could be a person on their phone, a developer using your library, someone trying to maintain it after you’ve moved on. Empathy gives you the fundamental value system to help you make your decisions.
Develop a sense of suck
The corollary of having empathy is developing an instinct for spotting things that suck. It’s hard to define what suck is, but you know it when you see it: a hostile UI, an awkward API, “the smartest newbie” kind of code that tries to be super clever, etc.
Resist designs that suck. Push back hard if you’re on a team. Designs that suck are the embodiment of technical debt. Learn to smell the suck before it’s integrated into your code base.
But I caution: don’t over-rely on detecting suck, lest you turn into a curmudgeon. Remember, empathy and the sense of suck are two sides of the same coin. You mustn’t neglect one in favor of the other.
Idiomatic is better
Every language and platform has its own set of programming idioms; embrace them. Use the abstractions already present in the system SDK instead of writing your own. Use the coding style espoused by the platform owners.
Idiomatic, unsurprising code is easier for maintainers to understand, and as a consequence, becomes less error-prone as it evolves over time.
Simpler is better
Complexity begets errors and fragility. The more things a maintainer has to keep in their mind as they work on your code, the more errors they will make.
Simplify until you are left with the bare essentials to solve your problem. It helps to divide your job into smaller tasks until each is small enough to be solved with a simple piece of code.
Smaller files are better
An even moderately-large source file is a good indication that you should split out whatever it contains. Don’t be afraid to sprinkle dozens of small files that have self-contained, testable amounts of code that do a few things well.
Simpler algorithms are better
Simplicity applies doubly to algorithms. Use the simplest algorithm that meets your needs. No need for a balanced tree if linear array traversal does the job in reasonable time. Do not solve a problem bigger than your problem space.
Simpler tasks are better
Write smaller, isolated commits more often; it helps you to maintain a sense of confident progress. It’s better to make many small steps that you know are correct, rather than one big one that’s hard to reason about.
Optimize later
“Premature optimization is the root of all evil”, Donald Knuth famously said. Code clarity and optimization are usually mutually opposed, so hand-optimize only where it pays off. Unless some code is meant to be called often, it’s better to write plain code than optimize.
Trust the optimizer. Write plainer, easier-to-read code. The optimizer will do the clever things for you.
Abstract sparingly
Abstractions exact a mental load, so use them sparingly and with intent. It’s easier to work with concrete instances than abstract protocols, so create abstractions only when they add value. Make sure you can articulate why an abstraction should exist.
Premature abstraction is second only to premature optimization in its potential for harm.
Mind your interfaces
API and network protocol definitions are important especially when working with others. Spend time to refine your interfaces.
Encapsulate
A successful API hides irrelevant details. It exposes supported interactions and makes it difficult to mistakenly request unsupported ones.
Declare what, not how
Ask for what the caller wants to be done, not how it should be done. Capturing a caller’s intent allows implementations to intelligently change how the task is performed without breaking the contract.
A counterexample to this principle is allowing a caller to pass in arbitrary scripts that the implementation has to interpret. This often ties the interface to one specific implementation. The risk of exposing internal behavior increases, so Hyrum’s Law applies doubly to such interfaces. Often, intensive bug-for-bug testing is required to prevent regressions.
Not every interface can be declarative. Some interfaces are valuable because they interpret arbitrary caller instructions—the web browser you’re reading this on implements such an interface—but don’t be surprised that they require a suite of regression-preventing tests, and invariably suffer incompatibilities in the face of dissimilar implementations.
Anticipate change
A successful API will change over time. Plan ahead for change, and describe how a new API revision will work with callers that use an older one.
Test what pays off
Before you write tests, ask yourself: how many bugs will this test help to catch over time, and how much will it cost to write and maintain? If the answer is “probably not that many” and “a lot of time and effort”, then consider skipping writing unit tests and rely on some other process instead.
App UI view code is a prime example of code that shouldn’t be unit-tested: it’s usually very hard to write and maintain good tests for it, and besides, it’s code intended to serve a human actually interacting with your program, so rely on human testing instead of unit tests.
For the rest of your small, beautifully-isolated, simple, functional code? Yes, do write tests for those.