The reason I bring this up is I am surprised that architectural choice is overwhelmingly the top technical debt concern. That doesn't seem right. I would expect it to be poor code due to time constraints.
1. The system grew alongside the devs' understanding of the problem space. There was never time to change decisions that were later found to be not the best (and the older such a decision is, the harder it is to fix).
2. The problem space changes over time. Either because the outside world changes, or because of scope or scale increases. This means that even good architectural decisions can become bad over time.
Isolated pieces of bad code can be fixes when/if they need to be touched for some other reason. Fixing currently-inappropriate architecture takes a bit more effort.
You try to make the best architectural decision that you can at the time, with the knowledge and resources you have available. Time passes and you learn new things -- maybe the problem changes, or your understanding of it improves, or your understanding of alternative implementation strategies improves. For whatever reason, you can now imagine a new architecture that would be superior, if only it were implemented to replace the old architecture. Now you have technical debt. This doesn't necessarily mean that the best decision now is to pay off the debt by reworking the architecture -- that depends upon a cost/benefit/opportunity cost analysis.
edit:
Tangentially, there's a pretty interesting presentation by Kevlin Henney titled "The Architecture of Uncertainty" [1]. My poor summary: When designing the initial architecture of a system, Kevlin suggests that the team brainstorm to identify which parts of the system have a lot of uncertainty. Each region of uncertainty then becomes a subsystem. Put interfaces between the subsystems that need to be connected. Hopefully you now have an architecture with stable interfaces, even if individual subsystems need to be completely rewritten during the course of the project.
Disclaimer I haven't watched the presentation, though I do have first hand experience refactoring large systems that grew slowly over time.
Based off of your edit's description, this technique feels like a patch that inevitably fall apart. It relies on the assumption that your team can correctly identify the centers of uncertainty, and that that uncertainty model will continue to apply. Thinking about such things is an excellent idea, but it is not sufficient - uncertainty is, after all, uncertain.
I think in many cases the more important thing is to create an abstraction that allows you to perfectly represent your existing business logic in the most concise way possible. This should let you cut down on the number of edge cases outside the model, and generally simplify the system. Simplicity in specification is important, because it will allows newcomers to quickly understand the inner workings of your code, quickly correlating business logic with real code - if they can understand it, and can work within it, then they will not be tempted to hack around it (which is the root of code deterioration). I strongly believe that human friendliness and understandability should be key design goals in ANY new system, not an after thought.
So long as no one breaks the abstraction, the 99%, day to day changes should be easy. When you finally do hit a case that requires a significant abstraction change, then your concise code will make it obvious that it's outside of your abstraction model, and can evaluate options at that point.
I have found the #1 source of architecture/reality mismatches over time being that "business" has deliberately kept developers only partially informed on a "need to know" basis.
To be clear, there was nothing malicious about it, it's just that many stakeholders only give their short term needs as input, not the long term strategy that's discussed behind closed doors in the board room. The big problem here is that non-engineers don't get that some strategies don't simply add to the problem space, but fundamentally change it.
"Why the fuck didn't you tell us sooner?" is one of the most common phrases in software development.
Quite a lot of the Scrum process is the valiant attempt to extract coherent requirements from an end user. You should be so lucky as for them to have a coherent plan at all, let alone a secret one. Insofar as they do have one, in my experience there's always a few bits with magical flying unicorn ponies as a requirement they'll get to in time.
The thing that is very frustrating in the "There was never time to change decisions..." bit is that there often is time.
If you are curious about what I mean, I invite you to try a little experiment. For a few weeks (2-4 should be sufficient) tell people, "If you see something wrong with the design, refactor it as soon as you see it. If you need some extra time on your story to compensate for that work, just bring it up at standup and we will modify the sprint commitment".
If your experience is like mine, you will find that the vast majority of people will not refactor the design at all. About 10% of the people will try to refactor something, and will end up trying to rewrite the entire app. They will do more damage than good and will probably give up half way through. If you are lucky, maybe 5% will actually refactor something and be successful.
Because I have tried this many times, I've interviewed people and asked them why they do what they do. For the people who don't refactor, the reason they usually give is: "There is no time to refactor". Which is really odd because they have explicitly been given time. What I have come to realize, though, is that people do not want time; they want absolution of responsibility. If you ask them to make the judgement call, they do the math in their head (unconsciously) and determine that they will be rewarded more and criticised less if they do feature work without refactoring. This forces the decision up to the PM/PGM/BM/BA/Whatever, for whom refactoring has no direct benefit. The result is that refactoring is rarely done, and if it is done it is the result of a large political process.
For the people who try to rewrite everything, training seems to be the overwhelming issue. They pull on a thread and the whole sweater comes apart. For some people in this category, though, giving them carte blanche to decide what to do means that they feel they can finally "do it right". Doing it right in this context means that they can replace all the code that they didn't personally write and therefore don't like. Since everyone on the team only writes a small portion of the code, it means that rewriting everybody else's portion is basically rewriting the app. Again, training seems to help because even if a person's goal is to replace everything, if they learn how to do it piece by piece they can be successful. Also if they do that, they will be required to have many coversations and may eventually learn how to work with others.
Finally, you may get one or two people who naturally know how to refactor well. It is useful to find out who these people are and to encourage them. Unfortunately, this often enrages the "my way or the highway" people. The people who are good at refactoring, if encouraged, will naturally dominate the design of the application. Often these people are suppressed by political means because they are so effective at driving the design. In order to enable these people you will need to make some tough decisions on the business end of things.
For the people who don't refactor, the reason they usually give is: "There is no time to refactor".
My #1 reason to avoid refactoring code is because that code has been there, is battle tested and hasn't had a bug filed against it in months. That is not my first instinct, either. There are lots of things I see that I had written months ago that I badly want to rewrite every time I see it. I have to restrain myself because it's not just the time spent refactoring. It's writing tests (if you're into that sort of thing). It's getting QA to hammer on it some more. It's fixing all the little bugs that you thought you had fixed that you re-broke. It's all the little bugs you've never seen before because this is a new design and you're not perfect.
Most of the time, it's not worth the refactor, even if it does slow down adding future features to that particular area. There are, of course, two exceptions: 1) if you're constantly playing bug whack-a-mole on a particular section and 2) if a section of the code you're working on is constantly changing. I grasp every opportunity to hold a meeting, stand on the nearest chair, strike a dramatic pose and shout "We are rewriting the loading system... FROM SCRATCH!" My project manager then tells me to get down and lay off the coffee, but that's ok, I've had my moment.
Certainly it is always a judgement call. I was just saying to my colleague yesterday that where a developer really makes a difference on a project is by consistently being able to make the right judgement calls. One can say there is no silver bullet, and while it is impossible to make a project go faster than it can, it's very possible to make it go orders of magnitude slower ;-)
On that note, a few things I try to keep in mind: As you say, don't gratuitously change code. You may hate the design, but if it ain't broke, don't fix it. This is probably the biggest mistake that "change the world" programmers make.
Second, try not to rewrite code -- ever. Usually there is no business case (see "don't gratuitously change code"). Even if you think there is a business case, it dramatically increases risk. My rule of thumb: anything that lasts longer than 2 weeks has a very likely chance of being cancelled. If you must rewrite, it has to take much less time than that.
Finally, keep in mind that refactoring is not rewriting, nor redesigning (though it is closer to the latter than the former). Refactoring is transforming the code so that it performs exactly the same function (bugs and all!!!) with a different "shape". Ideally you will have tools to help you refactor in such a way so that you can prove that the resultant code executes in exactly the same way as the original code. With or without the tools, you should have a suite of unit/integration tests that will alert you when you have made a mistake.
Refactoring allows you to slowly migrate code from one "shape" to another over time while not breaking it. People who are skilled at refactoring can evolve efficient design even starting with really badly written code.
Why do you want to do refactoring? While, as you point out, the cost of doing a work-around from a sub-optimal design is low, the cumulative cost of these work-arounds over time can be quite substantial. A work-around introduces complexity to the code. This complexity makes everything slightly more difficult and slightly more risky. It also makes further work-arounds more likely. These work-arounds compound the problem. Because poorly designed code usually has high coupling, problems in one area of the code can manifest in other areas without warning. As the work-arounds increase, the complexity can increase exponentially. As an example of a worst case scenario, I once worked on a project where the programmers averaged 1 line of code per day (Yes, LOC is a poor measure of productivity, but no matter how you slice it, that's just incredibly bad).
My experience has been that teams which refactor effectively outperform similarly skilled teams who don't refactor by a very large margin. Although productivity metrics are impossible, the difference is quite startling. Interestingly, I have also had some experience with teams that have very high test coverage, but which don't refactor consistently (or effectively). These teams also do not seem to benefit from dramatically improved performance. My current theory is to write tests to support refactoring and don't worry about any of the other benefits.
We're saying the same things, just in different ways. I was attempting to be humorous in my previous comment, preferring comedy to precision. :)
Interestingly, I have also had some experience with teams that have very high test coverage, but which don't refactor consistently (or effectively). These teams also do not seem to benefit from dramatically improved performance. My current theory is to write tests to support refactoring and don't worry about any of the other benefits.
That's something I had never considered. Do you have any general guidelines for testing for refactoring?
As a developer I've had some very good and very bad experiences... usually centered around two issues.. 1. I am not a morning person and am consistently late, I get work done, but not at 8/9am on the dot. And, 2. because I will take the time to understand what I am working on... this usually results in some refactoring and generally less code in the end. I once saved enough code in size to include lodash, and eventemitter into the client side of a project, by refactoring out a piece using those two libraries. I was of course chastised because it took longer than expected. I left when the writing on the wall was I would be fired anyway... That was over a year ago, and my understanding is the project I was working on with a 3 month delivery time still isn't done because no effort was made to resolve technical debt and they kept throwing more people/teams at it.
From the outside, it's rather hard to tell the difference between someone getting problems that are "inherently simple" and someone spending time finding simpler solutions to problems.
That's actually an interesting question. Aside from problems that are easily observable as difficult because lots of smart people have tried and failed, how can one objectively know whether it was difficult or easy?
Even within "just a web app" one can run into some gnarly issues or odd old code that renders otherwise simple tasks difficult, but since it's only one person working on it you can't really test whether or not it's actually difficult.
This might impact both the dev/manager disconnect as well as imposter syndrome.
> they do the math in their head (unconsciously) and determine that they will be rewarded more and criticised less if they do feature work without refactoring
Great point. They know they've been told they have time but they don't actually believe they have time.
1. The system grew alongside the devs' understanding of the problem space. There was never time to change decisions that were later found to be not the best (and the older such a decision is, the harder it is to fix).
2. The problem space changes over time. Either because the outside world changes, or because of scope or scale increases. This means that even good architectural decisions can become bad over time.
Isolated pieces of bad code can be fixes when/if they need to be touched for some other reason. Fixing currently-inappropriate architecture takes a bit more effort.