What is High Quality Code and How is it Written?
I would describe myself as a high quality coder. To say why it will help to explain what I think high quality code is by first outlining the process of coding which we aim to optimize. I will relate this to my overall understanding and approach to software development, and I will give examples of cases where I have written high-quality code.
In the course of software development, coders start from an idea of the software requirements and then study a codebase, and determine what change is necessary to make the codebase meet the new requirement. They must then implement the change, producing a software revision. Iteratively, the coder must determine whether this revision truly meets the new and all pre-existing requirements; if it does, then the revision is ready for review, if not, then the coder must study again, and produce another revision, etc. This outline may seem both elementary and oversimplified, but it is useful to lay out the basics so that a systematic understanding can be built. It is also necessary to strip out as much as possible to simplify the subject matter; such as by ignoring how the requirements are established, thus simplifying from the complex task of software engineering down to the practice of “coding”. Deriving from the outline above, high quality code is code that 1) can be quickly reasoned about to determine what changes are necessary, 2) tends to require smaller, faster changes, 3) it can be quickly determined whether the requirements are met, 4) require fewer iterations of failure-study-change before it’s established that the requirements are met. Note that in this cycle I’m deliberately leaving out the demarcation between one failure-study-change cycle and another, for the purpose of this analysis, discovering a failure to meet requirements has the same result whether it is discovered through reading the code, through manual tests, automated tests, code review, QA, or after release by observability alerts or user bug reports, all result in the recognition that code does not meet the requirements.
At this point the distinction can be made between high-quality software generally and high-quality code specifically. Both categories aim to optimize for one or more of the four steps above. First I will address practices for high quality code, and then for the overall software development process. These are:
- Read all relevant documentation for the project, to understand its components and their purpose. Discover and adhere to coding standards, and advocate for stricter standards where possible, without waiting to apply such standards to one’s own code, unless they are conflicting with other standards.
- Dedicating sufficient time for system design, and refactoring. I will omit much detail on how this can be done, suffice to say that Philosophy of Software Design is among my favourite books on development. I will only point out that my biggest takeaway from that book is the importance of “deep modules” which have a simple interface but complex internals, thus containing a lot of useful functionality, but insulating the rest of the codebase from the complexity.
- Write testable code, so that tests are easier and faster to write, and more of the requirements can be encoded into the tests. Focusing on testable code also enables adherence to the ideal testing pyramid. It is easier to write testable code under TDD, since our design of systems is centred around how that system can be tested. Testable code is more likely to be composed of deep modules; because the interfaces are simple, it is easier to consider all edge cases, whereas since they contain a lot of functionality, fewer test suites are required to cover all the logic.
- When giving estimates of time, explicitly count writing automated tests and refactoring as part of the estimate.
- Test driven development maintains automated tests as a form of software specification – if the tests pass, then the requirements are met
- TDD is often described as “writing tests first” which I find to be an accurate and useless description, since it doesn’t communicate what the tests are accomplishing. It is clearer to assert that TDD is tests-as-specification, comparable to a JIRA ticket that enforces itself 1) Type driven development encodes software requirements in the type system of a program itself, making invalid states unrepresentable in code – if the program compiles, then the requirements are met.
- Examples: typestate, ts-rest with separate client objects 2) Combining all of the above, code use the “test-refactor sandwich” by which testing and refactoring and the first and last activities while working on a feature. We should remember Martin Fowler’s opinion from Refactoring here, that refactoring can only occur on a foundation of automated tests. I have seen (and perpetrated) too many examples of beginning a refactor effort to improve reliability only to have the opposite effect due to the absence of automated tests. We first write tests so that the refactoring will be reliable, and we do the latter so our additional work can be done on a more solid foundation. We then test and refactor again so that our work will be clearer for those who will read it later, including reviewers. 3) It is acceptable and necessary to use LLMs, which are an amazing tool for modern software development, but ALL code submitted for review should have been read and edited by the responsible coder; no code should be submitted if after studying it, the coder either isn’t certain what it does, or would have chosen a different, better implementation had they written it by hand.
In contrast the overall practices for reliable software are:
- Writing clear, concise, and well-maintained documentation integrated within the repo itself
- Clear so that a future coder is inclined to read the documentation in the first place
- Concise so that it is easy for a coder to find the relevant parts
- Well maintained because code changes over time, if documentation isn’t kept up-to-date then it can become harmful by actively misleading the reader. An excellent example is Rust’s doc-tests, in which code examples as part of documentation can be run in a testing framework, ensuring that documentation examples can never be broken.
-
Prefer systematic approaches to bugfixes rather than patching (although this is acceptable in the short term eg. to urgently solve a production issue). This means identifying sources of repeated errors, and building a system that eliminates classes of bug. These will often be decided on in a post-mortem discussion, but they should not be limited to such meetings, even bugs which never get to production still cost time to fix.
-
Have retrospective meetings to discuss pain points in the codebase and development process. In these meetings, specifically allocate development time to execute the decision for how these pain points will be addressed, including by taking on refactor projects larger than what it would be appropriate or possible for an individual to take on under their own initiative or as part of other work. This is also a good opportunity to evaluate, with reference to DevOps metrics, the result of previous process changes/experiments.
-
Make time for group design sessions for major components. Major decisions, especially for project architecture, should be documented in terms of the pros and cons of the different alternatives.
-
Create and adhere to reasonable coding standards across a team and codebase. This will improve consistently and reduce the time it takes to understand code.
-
Engage in thorough code review. Adopt other practices that make code review easier. Configure an LLM or linter to automate the review process for simpler issues so that human eyes can focus on overall design.
-
Implement CI/CD with automated tests organized into an ideal testing pyramid, integrate static security and quality scanning, integrate code coverage requirements
-
Test software in an environment that is as production-like as possible
-
Create a high trust culture where developers are not afraid to admit mistakes and to take risks; and where when mistakes occur they are treated as collective/process failures rather than individual failures.
-
When standards are changed, or an existing tool or module is resolved to be replaced, or a major refactoring project is undertaken, it is necessary to make a detailed plan for how the codebase will be completely migrated. Otherwise it is very easy to begin a transition but then progress slows as priorities change, with the result that the project has multiple competing standards, which reduces clarity, and results in a “worst of both worlds” situation.
It is useful to point out that several of the above principles fall into the DevOps principle of “shift-left”; which states that problems are less costly to solve the earlier in the development cycle they are noticed. So the least cost comes from solving problems in design, a little more from compilation errors, a little more from unit tests, then integration tests, then manual tests, review, automated metrics & alerts, and finally user-reported production issues which are the most costly of all.
I aim to apply the above principles to make high quality code; several examples come to mind. While working on a React codebase with Redux state management, I noticed that a bug would occasionally arise where a request was sent to update some data, but once the request completed, the store data was not updated. This problem recurred for different data types in different places. I addressed this by introducing React Query to replace Redux for our client-side store. Thus, the stateful logic was entirely encompassed by RQ’s caching feature. So, rather than play whack-a-mole, we eliminated the entire “outdated store” class of possible bugs. To use the terminology of Philosophy of Software Design this type of bug was an example of obscurity, where it wasn’t clear to the coder that a dependency existed between the store and query modules; and the library introduction eliminated this dependency. Another example of eliminating bug classes was my introduction of Zod into a typescript lambda function backend. This was an application of the phrase “parse, don’t validate”. By parsing untyped json request data into endpoint-specific types, we avoided the repetition of incorrect validation bugs, where we would write validation code at the beginning of a request handler, which might have some bug in it, and then make incorrect assumptions about the request structure later on.
Of these, I think the most important principle is testability & automated testing. I like to model codebases as an acyclic directed graph of modules, where imports and dependencies are the edges on the graph. Complexity is a function of the number of edges in the graph. As a codebase grows, nodes and edges are added, and the complexity increases at best linearly (usually faster than linearly). The amount of testing required to ensure reliability is determined by the overall complexity of the system. Although complexity grows (at best) linearly, capacity for manual testing typically does not grow at all, as it is determined by the person-hours dedicated to QA. If we rely on manual testing, the result is that the ratio of QA person hours to system complexity is reduced, which also reduces reliability of the application. The solution is to ensure that QA keeps pace with complexity, which can only be done if QA is automated.