Low-Maintenance Software Development

Goals

  • No technical debt.
  • The software design is easy to understand, and when modification is needed, the corresponding code is easy to find.
  • Software prioritizes maintainability, allowing developers to return and improve it years after forgetting about it.
  • Software approaches a state of completion, remaining satisfactory until it becomes outdated due to environmental changes or new requirements emerge.
  • Dependencies with the same major version can be confidently upgraded.
  • When software is deprecated, it can be smoothly removed from existing systems.

Principles

Remember "there's no free lunch"; you can't get a low-maintenance software project by doing nothing. The key to achieving low-maintenance software development is not to make fewer mistakes, but to do every small thing right as much as possible, which is undoubtedly quite challenging.
Here are some general principles summarized to help achieve low-maintenance software development:
  • Discard all non-essential features.
  • Stop using languages and toolchains with unhealthy ecosystems.
  • Use highly expressive languages and avoid twisting and deviating from common language patterns during coding.
  • Stop using macros for any purpose that might lead to code smells.
  • Only use languages with type definitions or type annotations.
  • Use time-tested technologies as much as possible, as these technologies are likely to exist longer.
  • Think globally, avoid tactical programming.
  • Actively refactor code, allowing substantial line-of-code changes to reduce software entropy.
  • Cut less essential designs by shifting complexity to the user, as user-friendly designs are often the hardest to maintain. There are many similar examples in reality. Let's consider Markdown editors and WYSIWYG editors: The former requires users to invest (a minimal amount of) learning cost, while the latter has almost no learning cost. However, the latter is far worse than the former in terms of maintainability:
    • Markdown editors make it easier to write well-structured, human-readable plain text files. WYSIWYG editors produce either internal document models that are difficult to migrate, or HTML that is completely chaotic.
    • Markdown documents can still be used in 2050, and Markdown editors are often minimalist and replaceable. WYSIWYG editors become obsolete more easily, maintaining backward compatibility is harder, they are more difficult for developers to maintain, and the vendor may well no longer exist in 2050.
  • Stop using large software frameworks that can lead to vendor lock-in. When a software framework tries to adapt to every environment or attempts to provide a one-stop solution, it is no longer a software framework but a ticking time bomb.
  • Minimize dependence on low-quality packages as much as possible. This means code quality review of dependencies; fortunately, bad code style is easy to identify, often allowing judgment without truly understanding how the package works.
  • Do not write software for platforms that lack backward compatibility.
  • Unless the software has a single function, every effort should be made to avoid making it cross-platform.
  • Prohibit the use of proprietary services provided by cloud computing platforms.
  • Prohibit package managers that cannot lock sub-dependency versions, and prohibit using Git repositories as package sources.
  • Do not cater to existing standards and protocols unless necessary:
    • Most standards and protocols are garbage; most of them don't even have reference value, and catering to them will only hinder software development. For example, you might unfortunately encounter requirements that cannot be implemented with these standards and protocols, then fall into a dilemma.
    • Those standards and protocols that are so popular they cannot be ignored derive most of their popularity from their first-mover advantage and guarantees of backward compatibility. This leads to their production implementations being filled with various strange edge cases, and collecting and dealing with these edge cases can be exhausting.
  • Focus on the abstraction level of the toolchain; stop using toolchains that are difficult to migrate or upgrade.
  • Write extensive automated integration tests to enhance a sense of security during dependency upgrades.
  • The environment required for software operation should be dedicated and isolated, not shared with other projects.
  • Emphasize technological self-reliance, stripping away dependencies that might be affected by external factors as much as possible.
  • Minimize the number of developers outside of the core development team as much as possible, to avoid damaging project development efficiency due to collaboration friction. This is like a traffic jam; the best solution is not to widen the road, but to reduce the number of vehicles on it simultaneously.

Reinventing the Wheel

Reinventing the wheel is an anti-pattern because there is always one or more already-invented wheels waiting for you to reuse. However, reusing existing wheels often leads to dependency management issues: things start smoothly, but problems soon follow. The simplest path is often the one that maximizes technical debt. Walking this path often leads to frustration. Over time, even the most confident and passionate people develop learned helplessness, believing software development is an endless chore.
Judging whether a dependency is worth introducing is an important ability that is easily overlooked, supported by a wealth of unspoken experience. When you lack this ability, you are easily deceived, becoming a victim or even an accomplice to future harm. Because most developers lack this ability, the phenomenon of bad money driving out good is very common in the industry.
Open-source software projects of a certain scale can easily turn into "shitpiles":
  • Wrong abstractions, terrible architecture, dreadful API surface area.
  • Lack of convincing tests.
  • Documentation is not actively maintained, is inconsistent with reality, or hides/ignores useful details.
  • Too much tactical coding, too little strategic coding; the amount of new code always exceeds the amount of reduced code.
  • Investing excessive time costs for non-existent requirements, leading to software bloat and decay.
  • ...
Unless there are authoritative maintainers in the community who can provide strategic oversight and strictly enforce code reviews, the participatory model of open-source software naturally creates such "shitpiles," no matter how skilled the contributors are. Closed-source software has the same problems, but its opacity further conceals them; problems swept under the rug do not disappear.
To break free from technical debt, sometimes you just have to wear the NIH hat. By reinventing the wheel, you might notice that, in addition to freeing us from terrible third-party dependencies, reinventing the wheel offers benefits that developers burdened by technical debt can never know:
  • You might invent a better wheel.
  • You will become increasingly skilled at inventing solutions.
  • You will become increasingly capable of discerning good from bad dependencies.
When aiming for low maintenance costs, the benefits of reinventing the wheel might be further amplified in the future: After shedding debt, all that remains is interest—a simple truth. Over time, the accumulation of these positive feedbacks will make the option of reinventing the wheel increasingly important in your mind. However, before reinventing the wheel, its economic viability should always be carefully assessed. Reinventing everything is still undesirable; clearly, some wheels have a low cost-benefit ratio for reinvention and a high risk of development failure, so you need to be careful.

Building Battery-Included Dependencies

Some programming languages and frameworks claim to be "batteries included," meaning they have built-in high-level functionalities that can be used directly. The essence of these "batteries" is simply relatively stable high-level APIs that strive to avoid exposing low-level details; correct abstraction and testing ensure they rarely have breaking changes.
The concept of "batteries" can be fully applied to dependency management; one can establish a "battery-included dependency" in this way: This dependency acts as an intermediate layer between the project actually using the dependency and the dependency itself. It provides an ordered and tidy set of APIs based on the dependency, thereby allowing business code to avoid directly using functions or classes with gigantic or numerous parameters invented by "barbarians."
Since you only focus on the parts used in these dependencies, you can write your own test code in the intermediate layer to ensure that their behavior still meets expectations when dependency versions are updated. When a dependency becomes invalid in the future, you can choose to use an older version, replace the dependency, or implement it yourself; the previously written tests are used to ensure validity during refactoring. This is the key difference between batteries you build yourself and those built by others: you can always recharge or expand your own batteries, whereas for others' batteries, you usually just watch them slowly run out.
Due to formal similarity, battery-included dependencies might be confused with encapsulated business logic. The main difference between the two lies in their distance from the dependency. Battery-included dependencies are usually very close to the dependency, while business logic is typically very far from it. Only in a few cases, such as when business logic is built directly upon relevant dependencies, do the two tend to be the same.
Battery-included dependencies seem to be the holy grail for solving dependency management problems, but there are still a few issues:
  • After all, it is an additional intermediate layer; when new features are needed or breaking changes are made, it is more troublesome than directly using the dependency.
  • Establishing the correct abstraction is difficult. On the path to figuring out the right abstraction, sometimes it's inevitable to have to start over. People often lack foresight regarding future requirements, so an abstraction that is correct for a time might appear incorrect in the future. People fear projects getting stuck in a cycle of repeated modifications due to wrong abstractions, so they would rather get caught in a larger whirlpool, at least blaming others is always easier when mistakes happen, and it's hard to argue against this idea.

Using Small Packages

Small packages are a common embodiment of the Unix philosophy and semantic versioning. The smaller the granularity of a package, the fewer functions a single package has; the fewer functions, the more stable it is, and the fewer things will break when code is changed.
Small packages can sometimes introduce new problems, such as management issues caused by a surge in package count. These problems can be tricky because they become increasingly difficult to solve by brute force over time. This trend eventually forces out specialized solutions, such as Facebook's codemod-like tools, but using these things is still troublesome.

Prioritize Readability Over Performance

In actual coding, situations arise where one oscillates between readability and performance. In such cases, developers can easily choose performance over readability due to short-sightedness. Choosing performance usually lacks strong supporting reasons, and the trade-off of sacrificing readability for performance often proves not worthwhile in retrospect. In some cases, developers choose performance simply because of the childish psychological gratification of "writing high-performance code makes me seem smart."
When you are wavering between readability and performance, you should always choose readability; this cannot be stressed enough. Absolutely certain this code will only be written once and never changed in this lifetime? Choose readability. Code that is 1000 times slower but more readable? Choose readability. If a genuinely observable negative performance impact arises, then write another piece of code: This code is a high-performance version translated from the highly readable code, and you need to write very detailed documentation next to it explaining how this code was produced.
An exception is when you need to write high-performance programs; since your initial goal is high performance, readability as an option disappears. However, even for high-performance programs, there are still opportunities to improve code readability, though these opportunities are hard to perceive—Often, they are only noticed when the code is written a second time. And once such opportunities to improve readability are noticed, there is no reason to continue ignoring them—While maintaining performance, readability should always be improved as much as possible.

Appreciating Maintenance-Free Software

The "maintenance-freeness" of software should be appreciated as a form of beauty. The longer a software's "maintenance-free period," the longer its "maintenance-freeness" is tested, and the more beauty it accumulates.
The crucial part is that you clearly know the software is not unmaintainable, nor is it difficult to maintain, but rather it doesn't need maintenance. The software code is there, but you don't need to tinker with it because it still meets needs, functions normally, and works very well.
Even when maintenance is needed, the resistance to maintaining the software should be like a paper window, easily broken through with a gentle poke. Because there is no technical debt, you don't need to pay interest. Obsessive-compulsive urge to upgrade dependencies? No problem, because this upgrade can be done in ten minutes, and you have many ten minutes to waste. But even better is when you overcome yourself and stop your actions in time—Not just because no new requirements have emerged, but also because you don't want to destroy the beauty that the software has accumulated over time.
Cultivating this appreciation is quite difficult because it is counter-intuitive. People unconsciously mistakenly view the novelty of something as a measure of its quality—Keeping up with trends sometimes seems more important than anything else—A little thought reveals this is not rational. Just in daily life, one can find that well-built, maturely crafted devices often last unexpectedly long—Refrigerators, which operate continuously, are a prime example; few people replace their refrigerators annually. Software, as a digital product, has no reason to become obsolete faster than hardware, and ease of update should be the least reason to update.