At a high level, maintainability defines the ease with which changes can be made correctly. Correctness in this sense means that the intended changes are made without introducing unexpected side effects. Code should be structured so as to be easily modifiable. Tests should be in place to prevent regression, ensuring that existing functionality is unaffected by changes. Additional tests should be developed to verify new functionality. By developing tests concurrently with code changes, you are both validating that the change being made is the one intended to be made, and providing regression protection for future changes. Further, automated processes should be in place to continuously verify the correctness of the code base and to identify any issues as early in the process as possible.
Maintainable code is clear, readable, testable, understandable, well-organized, consistent, and highly cohesive. It eases ongoing enhancements, bug fixes, and modifications; quickens the ability to onboard new team members, transfer responsibility, and overall increases confidence in changes. It is enabled by high quality code, verified by a mature automated test and scan process, and facilitated by a close relationship between the business, designers, developers, and testers.
From a development standpoint, there are a number of aspects that can be followed to increase maintainability. This blog will briefly go over some of those qualities, focusing on how developers can structure code. Not discussed in detail are the multitude of ways and tools that can be employed to verify and enforce code quality, both during local development and as part of an automated continuous integration process.
Follow a clean and consistent coding standard
There should be a formalized coding standard followed by all developers. This enables an application with many contributors to have a single consistent standard, which increases the ability to make modifications. There are many tools that can verify, enforce, and automatically correct code style based on customizable rules, including: ESLint (JavaScript), TSLint (TypeScript), Prettier (multiple languages), RuboCop (Ruby), and Checkstyle (Java). These tools can be both incorporated into a developer’s IDE for scanning during development, and into a continuous integration (CI) process. Where feasible, use a community-maintained ruleset. This enables the application not only to be internally consistent but to be externally consistent with best practices.
Use human readable and sensible names
Variable, method, and class names should both follow a defined structure, and be human readable and descriptive of their intended purpose. Camel case (e.g. camelCase) and snake case (e.g. snake_case) are two examples of how to structure variable names. It is recommended that different types of components (e.g. classes and objects) follow different standards. For example, the standard in many languages is that classes should be upper camel case (or pascal case) while objects should be lower camel case. This standardization can easily be enforced by the tools discussed in the previous section. Not only should variable names be similarly structured, but they should also be sufficiently descriptive. The variable `firstName` is much more descriptive than the variable `aString`, and its intended purpose is easily understood.
Be clear and concise
While it may be tempting to develop applications with the minimal amount of lines possible, there is a risk of making the code overly complex. Clever and complicated logic should be minimized, with a preference for verbosity and ease of understanding over complexity. Code should focus more on what is being done rather than how it is being done. Code comments should not be required to explain the logic and flow.
Minimize complex conditional and nested logic
Nested code should be minimized as it is more difficult to follow the possible paths and logic flow of deeply nested code blocks. It is preferable to extract conditional logic to methods or variables so it is easier to understand what is being evaluated. Early method returns are preferable to large blocks of code contained within a conditional. Exceptions should be thrown as applicable.
Methods should be small and singularly focused
Methods should do one thing and do it well. This will naturally lead to reasonably sized methods. To improve readability, and if methods are becoming overly complex or large, look for opportunities to pull out logic into separate methods. It is preferable to have methods that do not require external dependencies and do not mutate external state. While code line length is very subjective, and it is a mistake to put in place a strict limit, it is reasonable that a sufficiently focused method would be 25 lines or fewer.
Classes should be focused
Similar to methods, classes should also be focused. All class methods and parameters should be related to solving a specific need, having a cohesive responsibility. Again, code line length is very subjective, but it is reasonable that a sufficiently focused class would be 1000 lines or fewer. If all other points are followed, class size will inherently be minimized while still maintaining readability.
Decouple and organize
The code should be decoupled and organized. This can be manifested in a Model-View-Controller (MVC) organization, or by using a similarly enforced architecture. Concerns should be separated. Two common ways of organizing files is either by type or by subject. For example, organizing files by type would be grouping all models together, while organizing files by subject would be grouping all User-related files together. Dependency injection is preferred to increase decoupling and improve testability.
Minimize redundancy
Common code should be extracted to shared methods and utility libraries. When code changes are inevitably required, they only need to be made in a single location. This aspect is focused on the Don’t repeat yourself (DRY) principle. Using well-designed frameworks can help reduce redundant boilerplate code.
Leverage existing libraries and frameworks
It is better to leverage existing libraries and frameworks than it is to recreate the wheel. Ensure that the libraries chosen are actively maintained and from reputable sources. Using third-party libraries lowers the amount of code needed to be tested and maintained.
Clearly track dependencies
All dependencies should be explicitly declared and used. There should be no dependence on implicit or system level packages. A manifest file should be used to track all dependencies (and the exact versions), along with a process in place to ensure these dependencies are downloaded and used. This allows developers and the CI process to remain in sync, and quickens the ability of a new developer to begin contributing. Dependencies that are no longer used should be removed from the manifest file.
Remove unused code
Unused and orphaned code should be removed. Removing unused functionality makes clear what code needs to be maintained and tested, increasing its readability. It is insufficient to simply comment out the unused code for possible use in the future. With modern version control systems, old code can be easily retrieved if it is determined that it is once more needed. If developing an externally used API or library, a deprecation strategy should be employed. Related to this topic is to avoid TODO or similar comments. Missing features or changes should be tracked externally, and not littered throughout the code.
Create API and method-level documentation/contracts
It is far better to write self-documenting code and to not rely on inline comments to explain logic. However, this does not mean that there should be zero documentation. This is especially true when working with multiple teams or when developing a consumable library or extensible application. The public API should be accurately documented, describing the inputs, outputs, and functionality. This enables API consumers to understand and use the available functionality.
Separate configuration from code
Configuration should be clear, consistent, and separate from the source code. It should be organized and defined in a central location. All available options should be described with how they are used and any applicable default values. It is preferable to use environment variables. In this way, the source code can remain unchanged, and how those environment variables are defined can change based on use case or environment. Anything that can vary between environments or deployments should not be located within the code itself. Organizing and sufficiently describing the available configuration options makes it much easier to deploy to new environments and for new team members to begin contributing.