Occasionally, things go horribly wrong in software development and we find ourselves in a code base where changing code has become a pain as every change breaks seemingly unrelated functionality and the code itself resembles an experimental art project. As a result, we spend most of our time fixing defects, sometimes even defects which we already fixed some months ago. We know that we have to do something, as the problem is getting worse with every line of code and if we don’t turn the trend around, we’ll end up in a software development swamp where velocity approaches zero and people hunger for a complete rewrite of the code base.
The good news is that there are solutions for this problem but none of them are miracle cures which just magically solve all our issues overnight. Sadly, the big rewrite in the sky will most likely never happen: The cost is too great and the risk is too high. After all, there is no guarantee that the new system will turn out better than the old one, especially if the same people are working on it. So, we have to figure out how we can improve things without rewriting large parts of our software and without stopping feature development and customer support. The best first step is to stop the decline, i.e. to prevent things from getting worse.
As we change our code to fix bugs or to introduce new features, we can either improve the quality (which is “incline”) or lower it (which is “decline”). If our product is in a bad state then we have declined too much in the past. That also implies that we will continue to decline further in the future. However, we cannot just stop making code changes as that would make our product obsolete over time, so we have to change the way we work instead and stopping the decline is the first step on this journey. To do that, we first have to pick the number one problem we want to focus on. It is tempting to try to tackle everything at once, but we need to resist that urge as our task is already hard enough as it is. We will make the changes in parallel to our regular work, so we won’t have a lot of capacity to work with. Examples for high priority problems are frequent regressions, a deteriorating architecture, low code quality in general, i. e. lot of code smells, and lack of modularization. Once we know what our number one problem is, we can then take suitable measures to stop the decline.
As a first example, let’s assume that our biggest problem at the moment is a deteriorating architecture. We have a single Git repository with certain layering rules (e.g., base functionality of our product must not call optional addon functionality). These rules were obeyed initially, but during recent development they have been violated frequently and now the architecture is declining towards a big ball of mud. What can be done? Clearly, we need some way to actually enforce the rules and preferably as an error instead of a warning as warnings tend to be ignored. However, we probably don’t have the time to restructure the application in a big bang as we also need to develop new features and provide customer support. The solution is to use a tool like CheckStyle or ArchUnit to automatically validate the architecture. In CheckStyle, we do this via the import control feature while in ArchUnit we’d write an automated test which verifies the architecture. Existing violations can be suppressed / whitelisted for the moment and then tackled later. This way new violations are prevented without a huge clean-up effort and the decline is stopped. If we are lucky, we can even use a tool which only checks new code and skip creating any exemptions. Adding a check like this can be done in a couple of hours and hence can be squeezed into a normal work week. It will not solve our problem completely, but just with this small investment, we have made sure that it won’t get worse in the future.
If we suffer from poor code quality in general, we can again use automated tools to prevent a further deterioration. A tool like SonarQube can work wonders here both to quantify the current issues in our code and to prevent new errors from sneaking in. SonarQube will scan any code changes for code smells and prevent them from reaching master if they are severe enough. We can configure what rules should be executed as well as their severity. The ability to just scan changed code is a huge advantage of SonarQube over something like CheckStyle or SpotBugs, however SonarQube is much slower. If it is feasible to prevent a code smell via a fast tool and the effort to completely eliminate it from the code base is manageable, we should take this route.
With that out of the way, let’s take a look at another example. What can we do to stop the decline if we are suffering from frequent regressions? Regressions can be prevented with a good automated test suite. If we suffer from frequent regressions, then there are insufficient tests and/or their quality is too low. Having not enough tests is a people problem, hence, we have to tackle it on a team level (see Why Your Developers Don’t Write Automated Tests for more information). We need to commit ourselves to always writing a suitable number of automated tests for every regression we fix. That doesn’t mean that we have to write tests for the full class which we are touching, though, as this might be too much effort in test-unfriendly legacy code. It is perfectly fine here to directly test a private method (or even better to move it to a dedicated class and make it public there) if that is where we made the fix. If we consistently do that, we will prevent regressions from slipping in again as the tests will find them. In addition, we will over time increase our test coverage for the whole application and also improve the architecture iteratively. Again, this is only a little bit of extra effort which we have to squeeze into our daily work. However, while every developer can (and should) do this on an individual level, it works much better if the whole team is onboard. If all people stick to the rule, the code coverage of the whole code base will increase as part of the daily work over time even if no time is explicitly dedicated to writing tests for legacy code.
For the last example - insufficient modularization - stopping the decline is hard as it is difficult to gauge proper modularization automatically. In general, poor modularization shows itself in very large repositories and classes. People just put all code in one place, because they have no better alternative. This is especially troublesome in classes as these tend to lose all structure and become public property instead of belonging to a single team. Proper structure can only be restored by a refactoring, but how can we prevent things from getting worse? One idea is to just restrict the creation of new files in an already bloated repository. However, this will only help if people also have a different place to put their code. Otherwise, they will just add all their logic to existing classes which in turn will make these even more unstructured. It is also possible to define a maximum number of lines for a single class in something like CheckStyle. This will force developers to split up overly large classes. It is a somewhat arbitrary check and no guarantee that the code split will make a lot of sense in the end, but it is something. Also, we might consider disallowing usages of certain code in new packages. Let’s say we have a legacy module which is still required but shouldn’t be used by any new code. We could add a static code check which prevents new code from accessing the legacy code (and add the existing usages as allowed exemptions). Even though our options are somewhat limited, we can at least do something.
To sum up, if we are unhappy with the state of our code, we should first stop the decline before planning any cleaning activity. Otherwise, we might face the same issues again soon.