Schools teach you how to write code—how to take an idea and turn it into software that does exactly what you want. This is great. It’s fundamental. What school doesn’t prepare you for, however, are the realities of maintaining a large codebase: continually changing requirements and shared ownership of code.
For that, you need to write code that is easy for other people to understand and to change. In other words, writing code that just does what you want is not enough. Other people have to modify your code to meet new requirements, and they need to do this all the time.
How do we build software that makes this easy? In this post, I’ll share some advice based on my own experience with complexity, readability, and architecture that can be applied to all modern languages.
Is good code always easy to understand? No.
Well-designed code should be easy to read, but isn’t necessarily easy to understand. Complex problems sometimes require complex solutions, and complexity that is essential to the solution can’t be eliminated. Instead, the goal of well-designed code is to produce small, functional units that are easily composed to perform complex tasks.
By optimizing for readability and architectural simplicity, we can make the code easier to work with, but the solution might not get any easier. The only way to deal with that issue is to modify the problem requirements so we can solve it more simply. This should be done whenever possible, but negotiating requirements is a large topic for another blog post.
An important element of readability (possibly the most important) is how you name things. Variables, functions, classes, and packages all have names. Names are expected to be accurate and complete summaries of the thing they represent. When people read your code, they are mostly reading the names that you’ve chosen.
Here are a few suggestions for naming:
- Think hard about names, because they set expectations for people reading your code. Don’t worry about being clever. Be honest.
- Think of names as forming a hierarchical outline of your program, where names act as summaries at various levels of detail. Remember that these summaries should be both accurate and complete.
- Choose names based on intent (`load_training_data`) and not implementation (`read_training_csv`). Implementations are more likely to change.
- If an accurate and complete name is too long, this is a sign that either the function is doing too much, or the code could be better organized. For example, if you have a method named `generate_invoice_widget_for_international_order`, consider naming it `invoice_widget` within a module named `Orders::International`.
- Reevaluate your names any time you make a code change, and walk back up the call stack to make sure the names are still appropriate.
Beware of comments! You might think that comments are responsible for summarization, but they probably shouldn’t be. This doesn’t mean you should never use comments. Instead recognize that comments are a source of technical debt. The further away a comment is from the code it describes, the higher the chance that it will get out of sync over time. Once this happens, the amount of effort needed to figure out whether the code or comment is right is very high.
One last point about readability: you are probably using a style guide and linter already, as dictated by your organization. If not, try to get one adopted. Style guides get everyone on the same page about how code should be laid out and what constructs are acceptable. This is a fairly easy way to improve readability once everyone has gotten used to the particular style guide you’ve chosen.
Developers use terms like “spaghetti code,” “coupling,” and even “connascence” to describe code that is not simple. You might even encounter someone who quotes “The Law of Demeter.” Another way to think about coupling is in terms of dependencies: what the code needs to know about in order to perform its function.
Dependencies make life hard for two reasons:
- If code needs to know about something, you need to know about it in order to understand what the code is doing. Remember that as a human, your working memory is limited. The more things you have to keep track of, the higher the chance you’ll get something wrong. This is why interfaces and abstractions are so important. They give us common patterns of behavior across a range of objects, decreasing the amount we need to keep track of.
- If one of your dependencies changes in a way you don’t anticipate, your code can fail.
In our Ruby code, we use bundler-audit and ruby-audit to keep track of security patches for our gems. A security update can force you to upgrade a library you depend on at any time, and if the new version has breaking changes, it can have a significant impact on your code.
Here are a few suggestions for reducing the impact of dependencies:
Functions should be as small as possible while still providing a useful unit of work. Pure functions are ideal, since they have no dependencies outside of their inputs and the functions that they call, and they don’t alter any other function’s dependencies (by modifying state).
- Use interfaces and encapsulation to reduce the surface of what any particular module depends on. Do this the first time a dependency has a breaking change that catches you unaware.
- Separate the parts of your code that are performing your business logic (what Moseley and Marks call “essential complexity”) from everything else: for example, optimizing for performance. The performance optimizations you make today will be found wanting tomorrow and will need to be changed.
- Keep in mind, “Simplicity is not about counting.” You will likely have more things when you simplify: more functions, more modules—you get the idea. This is good. The point of simplifying is to produce small functional units that can be modified independently and easily composed in the future. Be aware, however, that architecture and readability can clash. Sometimes, less code is more readable.
N.B.: There are other ways to deal with constantly changing requirements. For example, you could write code today that anticipates and accounts for future needs. This requires assumptions about the future, however, and the future is unknown to us. Unless you have extremely high confidence in what those needs will be, you shouldn’t do this. (As the saying goes, You Aren’t Gonna Need It. YAGNI is usually correct.)
- Requirements are constantly changing. Design for readability and architectural simplicity.
Treat names like summaries and choose names based on intent rather than implementation. Don’t rely on comments to explain your code.
- Our working memory is limited, so take advantage of the abstraction and encapsulation tools in your language to reduce the surface of dependencies.
- Always separate non-essential logic from your business logic. The first time you encounter a breaking change in a dependency, write a wrapper around it.
Hopefully this takes some of the magical thinking out of software development for you. Don’t get discouraged if you find these concepts difficult to put into practice. Keep working and it will get easier. Senior engineers find it difficult, too!