Experienced software teams know that to agree on the design of a project, you must first clearly define and communicate its requirements. But even when this is done well, disagreements over code design often persist due to different understandings of how the requirements are likely to change as the project evolves, and how to prepare for these changes. In this essay, I want to explore how the perceived volatility of requirements affects design decisions, particularly with respect to how code is factored. I’ll define a term — the diameter of a requirement — and suggest a coding principle that underlies many common coding practices: Code design should minimize the diameter of every requirement, but particularly those that are most likely to change.
As with the outside-in principle, understanding requirement diameters will allow you to both fill in the gaps between these coding practices and make better decisions about trade-offs in ambiguous situations.
The term requirement refers to a condition that is required for the software to be considered working. These are often formalized as lists of functional and non-functional requirements, but for this essay I’m going to use the term in a much more general way that includes everything from the high-level and abstract to the very narrow and detailed.
For example, consider a finance tracking app. An abstract requirement is that the app is about money. This is fundamental and unlikely to change. A more concrete requirement is that each user will have a single bank account. This will allow us to quickly build an MVP, but we know this will change fairly early in the next phase of development. Another requirement is that all bank accounts will be personal rather than business accounts. This might change or might not, depending on circumstances far removed from the development team.
The app will have many more requirements, most of which are either so abstract or so narrow that they would be implied rather than written in a formal requirements doc. In fact, most requirements will never even be explicitly discussed. But they will all affect how the code is written.
Each line of code will contribute to some number of the explicit and implicit requirements, and conversely the requirements will rely on different overlapping subsets of the code. The diameter of a requirement is a conceptual measurement of how far apart from each other are the lines of code that would need to change if the requirement changes. I don’t think it’s worth defining it as a number that we can calculate, but the idea is that the diameter is bigger if the lines of code are farther apart across a single file or are spread across multiple files.
For example, the requirement that our app’s name is “FinAccuTab” could be hard coded into every source file that involves displaying it. This would give it a very large diameter. Or it could be stored in a single line defining a constant that can be imported into other files. This would make the diameter as small as you can get. The requirement that the app has a single consistent name would still have a large diameter, but this is less likely to change than the requirement that the consistent name be “FinAccuTab”.
Another requirement that is enforced by a single five-line function has a slightly larger diameter. A requirement that involves 10 consecutive lines in a single file will have a smaller diameter than a requirement that involves two lines in two different files. And if these files are in different directories, the diameter is even larger.
The abstract requirement that the app is about money will have a huge diameter, across many of the source files. This diameters can’t be reduced, but it’s also very unlikely that this requirement will change.
Why it Matters
The smaller the diameter of a requirement, the easier it is to ensure that it is implemented correctly and consistently through a combination of automated testing and proofreading the code.
For the proofreading part, you need to look at all the lines of code that address the given requirement when it is introduced or changed. The longer this takes, as you scroll through the code and tab between files, the more likely you are to miss inconsistencies between what you saw first and what you saw last.
For the automated testing part, unit tests focus on individual components in isolation, so they mostly catch inconsistencies within a single component/file. As the requirement diameter spreads to more files, it will require coordinating between more unit tests. So keeping the unit tests consistent with respect to the requirement becomes harder. Integration tests can verify consistency across the whole project, but are expensive and typically not detailed enough to cover every requirement.
This matters when you first write the code, but it matters even more when the requirement changes. This is because the larger the diameter when you go to update the code, the greater the chance you’ll miss a section of code that needs to change. It also increases the chances that the code will be entwined with code for requirements that you don’t want to change, and might accidentally break. (This is often called “spaghetti code”.)
So, no matter how good your test coverage is or how meticulous your proofreading skills, keeping requirement diameters small will make both more effective.
For this reason, requirement diameters are implicit in many common coding practices, though I’m not aware of any place where it’s been explicitly described this way:
- Object Oriented Programming puts the definitions of data structures closer to the code that addresses the same requirements.
- The use of constants for magic strings reduces the diameter of the requirements associated with those values to a single line.
- Eliminating duplicate code reduces the diameter of any requirements involving the logic in that code.
- Making function and class responsibilities narrow separates the requirements addressed by the narrow functionality from the requirements about how that functionality interacts with the rest of the app, making both diameters smaller.
- Dependency injection has a similar effect, separating the requirements about how different components are related from requirement about the internals of those components, allowing both to have smaller diameters.
These are the most clear-cut examples of explicit rules, but if you look more generally at good coding practices from the perspective of requirement diameters, you’ll notice a pattern of keeping them small.
Unfortunately, reducing requirement diameters comes with a cost, which is part of the reason that new coders tend to make them large. For example, reducing diameters often involves indirection, which increase the overall number of lines of code, and is slower to implement than hard coding requirements. You can see this in many of the the coding rules above:
- Defining classes adds boilerplate code.
- Using constants for magic strings adds lines to define the constant and import it into other files.
- Splitting functions and classes into narrower chunks adds lines for function signatures.
- Dependency injection often increases the number lines or even the number of files needed to define each individual component.
But even if you’re conscientious of this as you write your code, it’s typically not possible to reduce all diameters because the indirection that reduces one requirement’s diameter will tend to increase the diameters of others. More generally, the different requirements for a project cover all the code one way or another, so reducing the diameter of one requirement will often pull on other requirements, making their diameters bigger.
This means that deciding how to factor a project’s code isn’t as simple reducing the diameters of all the requirements. It requires making decisions about which requirements should have small diameters, and which can have larger ones. This is where disagreements can arise, even when everyone agrees on the requirements themselves.
As noted above, the diameter of a requirement is particularly important when the requirement changes. This is the motivation for the second part of the principle stated above: Code design should minimize the diameter of every requirement, but particularly those that are most likely to change.
In order to apply this principle, you need to understand which requirements have the highest risk of changing. But more importantly, as noted above, different understandings of the requirements’ volatility will lead to disagreements about factoring, even if the team agrees in the requirements and the small-diameters principle. But if you recognize that this is the source of the disagreement, you can step back and discuss the requirements before you dig in on the design.
As with the outside-in principle, there are situations where reducing requirement diameters will be less important, or even counter-productive. Writing code this way takes longer, so if your goal is to learn something — learning a new language, experimenting with a new algorithm, etc. — you probably shouldn’t bother with the extra indirection, etc. required to reduce requirement diameters.
In fact, many requirements in this situation (to the extent that you have them at all) will be equally likely to change and choosing to reduce the diameter of one will force you to increase the diameters of others. So this could backfire if the wrong requirements change.
If you’re writing prototype code that’s going to be thrown away when you start building the production version, it likely won’t be around long enough for requirements to change. But there may be different versions of particular aspects/requirements that you want to compare. You can address this by minimizing the diameter of those requirements so that you can easily swap them in and out. Otherwise, you can choose which requirements to minimize based on how hard it will be to ensure consistency when you’re implementing them.
If you look back at factoring decisions that you’ve made, or that you’ve seen in others’ code, you should be able to recognize how they have increased or decreased the diameters of different requirements. The decisions that you recognize as good likely tended to decrease requirement diameters, particularly for requirements that were expected to change. Questionable decisions probably decreased the diameters of some requirements, while increasing those of others.
So, the next time you’re wondering why someone factored their code a certain way, or debating a design decision that you need to make (with your teammates or yourself), think about how different requirement diameters would be affected, and which ones need to be prioritized. This will move you beyond just following standard coding best-practices and help you to both make and justify better coding decisions.