Walking the Tightrope: 8 Tips for Refactoring an Active Application
If you are a developer who has been asked to rapidly implement new features in a heavily-used application — whose codebase is in various states of broken | unreadable | inefficient — you’re in a tough spot. Here are 8 tips (that I’ve learned the hard way) for walking the refactor tightrope.
1Stop talking $#!% about the legacy code. I get it. It sucks. Old code is hard to work through, but here’s the thing: all code sucks eventually. There is NO winning in the long run, and chances are your code will be discarded or refactored at some point in the future. So, next time you see a needless check, unreachable code, knotted logic, or uber-nested ternaries, note there should be refinement and move on. It’s a waste of time thinking of it as anything but an opportunity for improvement.
“When you find you have to add a feature to a program, and the program’s code is not structured in a convenient way to add the feature, first refactor the program to make it easy to add the feature, then add the feature.” ~Martin Fowler
2 When you are asked to add a feature, ask to refactor first. I often find myself in a difficult situation when I get a feature request, and it can’t wait for one of my precious refactors to get finished. Do I stash my refactor work and put the feature in just adding to the spaghetti-code, hoping to fix the design of it later? Or do I attempt to make the boss wait until my refactor is completely done (will it ever really be done)? This is the tightrope.
I was attempting to implement a more modern API in my app when I was asked to add a brand new feature to it. The feature couldn’t wait for me to convert the entire app to the new framework. My solution was to have two APIs for a while. It was not ideal, but neither was the situation. Within the ticket, I compromised with the product owner for more work-effort time to pay off some technical debt and help develop the new API rather than continue with the old one. In this situation the refactor could be anything: adding helpful tooling or bundling, implementing linting, adding test harness, updating a frontend framework — anything that helps work toward a better codebase. As a particular good selling point, if a refactor is done well during this feature request, each subsequent feature request of that nature should go quicker. One should note: having intermediate refactors like supporting two API platforms is not great, so following work efforts have to adhere to this pattern until the entire new refactor (the API in my case) is supported. Refactors in a live codebase can be a long and careful process, so be patient.
“You might want to explain this principle to the boss by using a medical analogy: think of the code that needs refactoring as a ‘growth.’ Removing it requires invasive surgery. You can go in now, and take it out while it is still small. Or, you could wait while it grows and spreads — but removing it then will be both more expensive and more dangerous. Wait even longer, and you may lose the patient entirely” ~Andrew Hunt, David Thomas
3 Comment with caution. Good comments are amazing; they can step you through a necessary, but complex, algorithm with plainly worded care and bring you to the promise land of comprehension. But most comments are like an avocado: you don’t really need them when they’re ripe and they go bad before you need them. By the time another developer is trying to figure out what you meant three years from now, that avocado is not good anymore. Instead, be declarative with variable, class, and function names — and lead the code with short obvious steps. It is way easier to remember to update variable names than the comments that won’t break your app if they’re not updated. If comments seem needed beyond that, make them clear and concise. Think about them. Maybe ask someone else what they think you meant by the comment and improve it if necessary. Then, when you work on the code again, make a point to remember to update the comments when updating the implementation around it!
4 Get Feedback, early and often, especially if you’re a relative newbie like myself. Chances are you are about to walk into a trap (that you even read you should avoid), but if you humble yourself with showing your work and explaining your strategy to another dev, they’ll probably give you good advice and save you time. If your idea is awesome, they’ll learn about something cool or even expand on your idea and make it better. This should be an iterative process, too. The more you check in the more your design, code style, and codebase as a whole will benefit.
“Refactoring changes the program in small steps. If you make a mistake, it is easy to find the bug.” ~Martin Fowler. Yes. Martin Fowler, again
5 Work small to big. Once you have an overall plan schemed out, break it apart with size in mind. Work efforts that take an hour or two, or ten minutes, should happen first. Keep the app working, test the code, establish a good pattern, and iterate on it. This is easiest with bite-size chunks. And if things don’t work out, and you have to refactor your refactor, it’s less painful than if you worked two weeks on a massive change.
So, gamble small. Be concise with your work efforts. Make a branch that has a clear mission and don’t deviate. If you see a piece of code that should change, but has nothing to do with what you’re doing, tag a TODO in a comment over it, and then leave it alone. That’s not what you’re doing right now. It’ll leave your work efforts unorganized and mess you up in the long run.
6 With that in mind, no BIG-BANG rewrites. I’ve often heard it said, “Never attempt a big-bang rewrite.” Rewriting an entire app from scratch is seldom the answer. Dream big, commit small. If you’re adding features, updating resources, changing schemas, fixing bugs, augmenting data processing — often all in parallel — then your forked refactor branch you’ve been working on in your spare time has less and less of a chance of making it into the rest of the codebase. So, try to get the small refactor tested/vetted and merged into the working codebase before other major changes happen. It will save you from git-conflict-hell and push the progress of your refactoring efforts in a more manageable way than supporting two branches that diverge more and more with every commit.
“A Fallacy of Software: If it works, and we don’t change anything, it will keep working.” ~Jessica Kerr
7Refactor before you refactor. Wait…what? I come across quite a bit of code that is just plain hard to understand: one-letter variable names, outdated coding practices, putting too many things on one line, or any other various bad practices. Making a small rewrite of the code you want to refactor, that performs exactly the same as before, can make a lot of headway to understanding how something works. Then, once you understand it, you can see how it can be improved! It’s hard to know that something could be handled better upstream if you can’t read the three times it’s written downstream.
I’ve had times when I found the same hard-to-read code in multiple places. Not only could it have been stored in a separate function, but it also performed some logic that was already declared somewhere else. I wouldn’t have known it was superfluous if I hadn’t rewritten it so I could read it. Making a piece of code readable with a ‘scratch refactor’ helps you understand its functionality. After it’s understood, consider how to streamline the logic in a simpler way in the next refactor iteration.
8 Test, refactor, test. I didn’t want to play the golden harp of unit testing here, but it really can save you a lot of grief to keep up on testing. It’s something I want to be more disciplined about. Testing prevents bugs. However, some legacy code I’ve found is just not written for tests. It may be a nested function that is out of scope for the testing framework or side-effect heavy procedure that returns nothing to test. If you can make small changes without changing so much that this approach itself breaks your app, put the part of the legacy code you’re working on in a more unit-testable form. You’ll be setting yourself up for success.
“Make sure you have good tests before refactoring. Run the tests as often as possible. That way you will know quickly if your changes have broken anything.” ~For the hat-trick: Martin Fowler
Techniques like putting pre-refactored code into its own function or method with parameters that do not rely on or mutate state can go a long way. Then write a few tests for it. After you’ve made your lovely refactor to the code, rework your tests until they pass. This can boost your confidence in your application and make your code more flexible and understandable… all without affecting functionality for the user.
Happy refactoring, and thanks for making the world a better place with more flexible, more readable code!
Here’s a few helpful resources I checked out when writing this article:
- How to Improve a Legacy Codebase
- Refactoring Legacy Code
- Refactor legacy code today with webpack!
- Why refactoring code is almost always better than rewriting it
- The Pragamatic Programmer by Andrew Hunt and David Thomas
- Refactoring: Improving the Design of Existing Code by Martin Fowler