Published · Updated
Software Quality From First Principles
Opinions, Everywhere
Ask a group of 10 developers what it means to write good code and you’ll probably get 10 answers. One declares “it works”. Another suggests “it runs quickly and can scale”. Yet another contends “it’s easy to work with”. Around the room the answers go; there’s as many possible answers as there are opinions—and amongst a group of developers, there’s certainly no shortage of those!
Which answer is correct? Are any of them? All of them? None of them? In fact, each of these answers touches on one aspect of what it means to write “good code”, yet none of them succeed in capturing the full scope of what that really means. Before we can accurately define what it means for code to be “good”, we must first examine the underlying reason we’re writing code to begin with. Only then can we begin to reason from first principles to deduce what practical implications we can use to judge the quality of a particular piece of code.
Understanding the Business Context
Why do we write code? For fun, fame, and fortune? While that may be a personal motivation for some, it’s usually not why we’re hired. At the end of the day, we’re employed to solve a business problem. More specifically, we’re usually tasked with implementing a technical solution to solve a particular problem of a certain business subdomain—something that will ultimately either reduce costs or increase revenue for the business that employs us. Intuitively, we can reason from this that “good code” must then be code that solves a business problem. Or, more precisely, we could say that good code solves more business problems than it creates. That’s it. That’s the Tweet.
Sorry, were you expecting a more “technical” answer? That’s the trouble with us developers: we instinctively want to jump straight to a low-level technical solution before first understanding the greater business context in which we operate. As we’ll soon see, there are many objective, technical qualities of “good code”, but without framing them with the proper business context, we’ll quickly descend into a world of “differing opinions” that is comprised of bickering and bikeshedding.
Does Quality Matter?
Some would argue that if code solves a business problem (i.e., “it works”), it would simply be wasteful to spend more time reworking the solution to “refactor” it just to pursue some abstract notion of “code quality”. If the goal of software development is to solve a business problem, it seems logical to assume that once we’ve done that any additional effort expended would be considered “waste”. The issue with this view is that it misses a key component of the “non-technical” answer we reasoned above. We don’t just need to solve business problems; we need to solve more business problems than we create. As it turns out, bad code can create lots of business problems. (Don’t believe me? Just ask anyone who’s ever worked on a legacy software system and had to implement a “simple” feature. I’m sure you’ll get some very detailed accounts of how long-ignored technical debt was dragging down both team productivity and morale.)
Bad Code == Bad Business
Every software developer who’s spent any length of time writing professional software likely has their own definition of it, but there’s one thing we’re all familiar with: technical debt. We use this term to describe a wide range of less-than-ideal code—things we feel could be faster to run or easier to read as well as things we really hope don’t come back to bite us later. (Spoiler: it will.)
What’s unfortunate about the term “technical debt” is that to a business, debt generally sounds like a good thing. Businesses are accustomed using “capital” to produce “profit”. Often, that means borrowing other people’s capital in the form of debt to make even more profit. Usually, however, this isn’t the sort of situation we software developers have in mind when we label something as “technical debt”. In fact, what we’re referring to is more akin to high-interest debt. Mountains of it. So much, in fact, that we’re completely “over-leveraged” and no reputable lenders want to give us more money for fear we’ll never pay it back. Every business person in the board room would likely be embarrassed to fall prey to the “payday loan trap”, yet due to an unfortunate misunderstanding of terminology this is almost exactly the same sort of situation that can arise with technical debt.
When we incur technical debt, we’re usually borrowing from our future selves. Payments against that “debt” are made in the form of time we could be spending working on other features to add new business value. The “interest” is paid by way of reduced “throughput” or “feature velocity”. (I.e., new features take longer to implement than they otherwise would.) The more “debt” we take out, the higher the “interest rate” we pay. As many discover much too late, there’s a tipping point where eventually we are just treading water making “interest-only payments” and can’t begin to fathom having the time to pay down the principle to get our rate lower. Left unchecked, this can lead to the “spiral of death” where we begin to fall behind on those “interest-only payments”, causing the technical debt to spiral out of control and leading to ruin. Depending on the project, that may mean the project is considered a failure, or it might mean an entire team or department gets the axe. At worst, it could represent an existential threat to the business itself. This is why technical debt is so insidious: it sounds like a good thing to those who don’t understand software development, so the business implications often go unnoticed until drastic measures are necessary to address it.
Illusion of Choice
Almost everything in software development is a tradeoff of some sort. Naturally, we assume that quality must be “balanced” with getting work out the door quickly, as if the two are competing goals. While perfect is the enemy of done, it’s critical to understand that a high-quality codebase is what enables us to go more quickly in the future.
The takeaway from all of this is that when we think we are making a choice between “quality” and “speed”, we are gravely misunderstanding the very nature of our craft. While most decisions are indeed nuanced and full of trade-offs, the notion of choosing between “quality” and “speed” is fundamentally a false choice. Not only do the two coexist together in harmony, they are inherently intertwined, one enabling the other. Thinking otherwise is an illusion that would be unprofessional to perpetuate.
Hallmarks of Good Code
Aside from solving a business problem (i.e., “it works”), there are two primary attributes I consider when determining whether something solves more business problems than it creates. While There are many facets to these, I consider them to be the supersets to which all other qualities are sub-members when we consider code quality from a business context.
Attribute #1: Good code is maintainable.
Maintainable code can be extended safely and efficiently. I consider this to be the core principle from which all of the other observations regarding software maintainability mentioned below are derived.
Automated tests provide safety when making changes. A fundamental part of being able to make changes safely is in having comprehensive automated tests that can be run against the codebase to tell us whether new changes will break existing functionality. The confidence we gain from these automated tests also helps us move more efficiently since they allow us to spend less time manually testing features and fixing things we didn’t realize were broken until much later.
Code is read more than it is written. Just think of the last time you implemented a code change to an existing software system. How much time was required to actually write new code to make your change as opposed to the time you spent reading the existing code to understand how things currently worked? Because of this imbalance, we should optimize for readability as much as we reasonably can to make the job easier for our future selves and other developers. It’s tempting to focus on the “easy” things like consistent formatting, meaningful variable names, using language-specific idioms, etc., but in my experience well-organized flow-of-logic is often more important to get right and harder to fix later.
Well-factored code reads like good prose. (Assuming you’re working with an adequately expressive high-level programming language anyway.) Martin Fowler once famously said “Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
Good naming is one of the single most important things you can practice. There’s a running joke in the industry that there are two hard things in Computer Science: naming things, cache invalidation, and off-by-one errors. Clear, descriptive variable names are well-worth the effort and can provide great benefits to the maintainability of a codebase. I’ve often found that if I’m having trouble giving something a good name, it’s an indication that I either don’t understand it well enough yet myself or a function is trying to do too much and needs to be refactored further.
Comments should provide context, not captions. If you’re working in an expressive, high-level language, there should be very little need for comments in well-factored code that adheres to good naming principles. If you find yourself using comments to describe what the code is doing, it’s probably a code smell that should be refactored. (Remember, we want the solution to be simple and obvious. Much like a joke, if you have to explain it the code probably isn’t very good.) The primary role of comments should be for providing context on why things are done a particular way or certain decisions were made when implementing a solution.
Code should be unsurprising and honest. Professional code isn’t the place for golfing or proving how clever you are. Ideally, reading code should be a boring activity without plot twists. Things should do exactly what they claim to do without lying about their purpose in their names.
Attribute #2: Good code is scalable.
Scalable code is about more than just supporting an increased user count or larger data set. Those are certainly key aspects of scalability, mind you. Every professional software developer should be able to reason about the basic time and space complexity tradeoffs of their code and understand at least the basics of big-O notation. What many developers overlook, however, is two other key aspects in which code needs to scale: team size and feature count.
Successful code will see many developers over its lifespan. A high-quality codebase is one that new developers can easily pick up and begin working in productively. If you find that only the original developers of a system can be productive in a particular codebase and new developers never seem to be as productive as you’d like, you may be served well to reconsider whether every new developer is really the problem or if maybe the problem is with your existing codebase. (Spoiler: if you have to ask, it’s probably your codebase.)
Codebases often start off with only a few developers but end up with many developers over time. Managing a codebase that’s actively worked on by 20-30+ developers is vastly different from managing one where only 2-3 people contribute code. With an order of magnitude of team growth, the previous economies of scale for things like code organization, version control, and deployment practices probably don’t hold. Things that were once simple are now exceedingly complex, creating a new form of technical debt where there wasn’t any previously. While it’s important to avoid premature optimization, keeping team growth in mind from the start can save a lot of pain later.
Code should support new and changing business needs. While many will pursue “DRY code” as a “pure good”, I’ve found that doing so can often be a misguided attempt that leads to what I like to call “DRY-rot”. Good abstractions are an important part of scaling a codebase to support new features, but having no abstraction is usually preferable to having a poor one. Once again, it’s important to avoid premature optimization and making overly-generic code can often just make it more difficult to implement a feature now and make it more difficult to extend later. Adhering to principles such as SOLID will go a long way towards ensuring code can support new and changing needs later. (Although originally conceived for OOP, I’ve found the principles outlined in SOLID to be applicable far beyond the scope of just objects and classes.)
Code should support business growth. The success of our business goals shouldn’t come as a surprise that overwhelms the software system and brings it crashing to a halt. Premature optimization should be avoided, but careful attention should be given to how a solution will scale under increased load, whether by way of additional users or larger data sets. Not every aspect needs to be optimized up front, but it’s important to make sure that we don’t paint ourselves into a corner where it’s exceedingly difficult to implement an optimization later for no better reason than we simply didn’t take care to think about what would happen if our business actually succeeded.
Software Professionalism
Good code isn’t just a question of developer happiness or business performance; it’s one of professionalism. As software developers, we are hired by the business to achieve business outcomes by using technology—often by business stakeholders who truly don’t understand all the nuances and implications of the tradeoffs we’re faced with on a daily basis. It is our responsibility to use our expertise to help the business reach its objectives; that means writing good code and helping others do the same.
Careful attention must be given to promote code quality. If left unchecked, we may find ourselves excusing certain bits of code since we want to “make the deadline” or some other reason we tell ourselves about why it’s okay to do a bad job “just this once”. If the deadline is fixed, scope should be the primary variable we consider for fluctuation, not quality, since as we’ve seen quality is a core component of being able to reach business objectives in the long run.
High-quality code is respectful to future developers (including yourself). Instead of taking the “easy” way out by rushing out poor quality code, think about the complete lifecycle of the code you’re writing. At some point down the road, someone (likely you or a teammate) will likely have to extend or modify the code you write today. Often, a few extra minutes today can save hours or days of rework in the future.
Good Coding Practices
Now that we understand the greater business context and what it really means to write “good code”, lets examine some specific practices and principles that promote that end.
A mindset of continuous learning and study is the foundation of software craftsmanship. Fortunately, it is not to us to discover the core principles of how to write quality software. While there exists a bright future of innovation ahead of us, we stand on the shoulders of giants and there are certain guiding principles which have been discovered and documented for us to learn from. Specifically, SOLID coding principles provide the basis for many concepts and practices that are universally applicable far beyond the scope of strongly-typed OOP.
Code reviews and pair programming promote software quality through shared learning. Sometimes we’ve worked on a problem for so long that we miss obvious errors or potential ways to achieve a more optimal solution. Having a peer involved in the development process helps provide valuable feedback towards achieving a better implementation. As an added benefit, it also means that someone else is familiar with that portion of the codebase, which helps promote team knowledge sharing.
Automated tests provide confidence in making changes. While there are many differing opinions on the “best” way to test code, the single most important principle is that you are testing your code in an automated fashion. Quality drives future productivity when working in a codebase; a key component of that is knowing we didn’t break anything with our latest changes.
Automated code analysis elevates the level of our problem solving. Most professional software development teams have a style guide they seek to adhere to. Without proper tooling, it’s easy for code reviews and other forms of peer feedback to become mired in trivialities such as formatting and syntax errors. Static analysis of our code using code linters and automated formatting tooling (e.g., ESLint and Prettier for JavaScript) can eliminate entire categories of potential issues from the scope of concern of human review. In addition to being a great productivity boost, it can also serve as a way to prevent bikeshedding within the team—any change to the style guide is now a Pull Request to change the configuration for the tool that enforces it.
Objective metrics derived through static analysis can provide a way to track code quality changes over time. In addition to streamlining concerns around style guide adherence and basic syntax issues, static analysis tooling can also be used to derive helpful metrics that provide an indication of code quality. (E.g., cyclomatic complexity, function LOC, framework-specific best practices and security issues, etc.) While much of coding is indeed an art, there is a great deal that can be reduced to objective science.
Using a CI check for each Pull Request is a great way to enforce code quality. By implementing various static analysis metrics as fitness functions that we can run against our code, we can create quality gates to allow or block changes based on predefined rules. Manual review still serves an important role in team knowledge sharing and in promoting overall code quality, but trivial issues such as syntax errors and style guide violations should never make it into main
. This is also a great place to run automated test suites to ensure that the function of the code is correct in addition to its form. (Running code in this way is an example of dynamic analysis, which is closely related to the static analysis tooling described above.)
Ongoing attention and care help improve code quality in the long run. Some teams enforce a policy that PR to a repo may only touch code directly related to a the feature at hand, but I’ve found that such heavy-handed rules can serve as an unnecessary point of friction for simple fixes. While sweeping formatting changes and large refactorings should certainly be done in a stand-alone changeset, smaller fixes usually cause no harm and are more likely to actually get done when allowed to be “batched” with other nearby changes in an otherwise “unrelated” PR.
Guiding Principles
Code quality isn’t just a nice-to-have to promote developer happiness; rather it is an integral component of meeting business needs. High quality code directly improves business outcomes by making it easier for developers to maintain code and scale it to meet future needs. For more on that topic, I’d highly recommend Martin Fowler’s excellent article titled “Is High Quality Software Worth the Cost?”.