I was recently involved in a great example of software complexity, technical debt, and refactoring, and I want to pass on the experience.
As part of a project some new requirements came in. I had been concerned that part of the system under development was a little complex, but not overly concerned, as it worked and had comprehensive automated tests.
But the new requirements changed that. They concerned a subsystem which took three inputs and processed them. The problem that the subsystem was trying to solve was roughly stated as follows:
We have an input stream which needs to be combined with two other input streams. But if some of the combining can’t take place because the input streams have gaps then we have to fill in the gaps from other sources. And then we can output the result.
If that seems a bit vague, it’s because it is: I hadn’t paid much attention to expressing the logic clearly.
The new requirement, meanwhile, was to add a fourth input. Since we were using a visual tool I can present the “as is” software design along with the “to be” that was the likely outcome of the requested changes:
The threatened “to be” state would have been excessively complex. Two things concerned me greatly. First was that I couldn’t keep all the “to be” logic in my head at one time, so didn’t have confidence that it was right. And second was that — as a guiding rule — software should be exactly as complex as the problem it attempts to address, and no more so… and that was not reflected here. My gut feeling was that the problem was simple, and adding one more input should have added linear complexity, but it felt like the complexity was growing exponentially. This was compounded by the fact that it was quite apparent there might soon be a fifth, sixth, or even seventh input.
The solution was to take a step back, express the logic of the problem clearly, and redesign the subsystem to reflect that. The problem, once clarified, became this:
We have a number of input streams which are merged. For each missing element of the merged stream we take action to fill it in. And then we output the result.
The refactored “as is” and the eventual “to be” logic became much simpler:
I hope you agree with me that the new flow is much clearer, and additional inputs add only linear complexity.
In terms of software development the sequence of steps was: (1) Make sure all the tests pass, (2) refactor the subsystem to reflect the new logic, (3) make sure all the tests still pass, (4) add the fourth input with appropriate tests.
For me this experience demonstrates a few things:
- If software internals aren’t continually kept clean and of good quality then complexity increases excessively and progress slows accordingly. Or to put it another way, technical debt is very well named: the longer you neglect the debt the more interest you pay.
- “Software should be exactly as complex as the problem it’s addressing” is a very good driver for reducing complexity.
- Automated tests save the day again! The refactoring above would have been quite daunting and unreliable without them. But instead the worst thing about the operation was that it was a little time-consuming. It was not risky, it was not stressful, and it was not really that difficult.
- Visual software development tools sure do expose messy software design for what it is.