Legacy codebases usually have one of two problems (usually both): they’re larger than they need to be, or they’re over-engineered. Features were built to meet requirements, but never actually used in production, an eager engineer created a ‘system’ of classes and services for a feature that’s used by a small percentage of users, a list of providers that had to be supported was actually much shorter than initially thought… you’ve heard this story before. These situations are unavoidable; they are inherent in the nature of software development.
Even so, there are real-world effects caused by these issues. Engineers require more time dedicated to supporting that larger codebase—time that could have a better ROI. Adding features to a bloated or stale codebase can easily triple an estimate for an otherwise-simple task. And then there’s the most impactful effect: the bloat, dead code, and over-engineering are acting as broken windows in your codebase.
There are many ways to solve this problem, but in my experience I’ve found just one results in real, verifiable change in a codebase: incremental reduction.
Incremental Reduction is the idea that the safest, most-effective solution to technical debt is making small, justifiable changes while you’re “in the area” and also have an advantage over past-you: hindsight.
Having the benefit of hindsight on a feature—seeing how it was actually used (or not used) **in production—provides you with the inverse of YAGNI: YDANI (You Didn’t Actually Need It). You Didn’t Actually Need all four of those provider implementations; only two are ever practically used. You Didn’t Actually Need to implement the same methods for each one of those classes—they have the same return values, they’d be better-suited in a Trait.
It sounds simple, and on a technical level it is. The challenge is getting buy-in from stakeholders and consistently doing it as you & other engineers go about your work. And once you get that buy-in, the desire from the ‘business side’ of a project to slowly move to a ‘dedicated time’ for addressing technical debt (and away from Incremental Reduction) never ends. Dear reader, I believe you already know as well as I do: that dedicated time will never arrive.
After encountering the same types of issues when implementing this at various organizations, I decided to compile a few ‘rules’ to protect buy-in from the business side, ensure integrity of the codebase, and ensure engineering time was still being efficiently used. You might have more, and that’s okay!
I’ve used these three rules to implement Incremental Reduction in multiple at-scale, legacy codebases, to great effect:
- Rule 1: No context, no change — Do not change any logic whose source (class, method, etc.) you are not actively working in. Past-you had in-depth context on the feature; present-you has likely forgotten most of it. If the work you’re doing doesn’t heavily-involve the feature, leave it alone.
- Rule 2: Refactor only, unless you have a good reason — Do not change the behavior of any logic unless you can plainly explain the reason to another engineer. Most of your changes should either be removing logic (or files) or consolidating it. Only you can answer what a ‘good reason’ is for your project; typically I find myself doing this only when I ‘gain’ the ability to remove a significant amount of logic elsewhere.
- Rule 3: No assumptions allowed — Any change you choose to make must be accompanied by QA on the specific scenario(s) you might have affected. If you choose to change logic in a given class, it’s your responsibility to fully-test that class with your changes. This rule is specifically intended to prevent regressions, which will erode buy-in from stakeholders of your project and defeats the point of doing Incremental Reduction.
I’d be remiss if I didn’t emphasize what’s hopefully already clear: only make changes that based on your context will reduce complexity. The tendency will be for engineers to interpret this as a license to go boy-scouting and make all those changes you’ve wanted, or implement a feature how you thought it should be. Remember: changing code that’s already in production has inherent risk. Incremental Reduction requires consistent, focused changes over time that push a codebase towards a more-stable state. Preferences do not achieve this goal.
Note that a ‘focused change’ doesn’t necessarily mean low LOC—on the contrary, it could genuinely be hundreds. But the conceptual change being is small. You aren’t rewriting entire classes or implementing a new design pattern. You’re consolidating to a trait, removing old providers, or deleting control paths that never saw use in production.