Refactoring legacy applications


6 min read

What's refactoring?

Refactoring is the process of improving the structure of code without changing its behavior. It can increase readability, maintainability, and performance. It can reduce technical debt. When a codebase is cluttered and outdated, there are two options: rewriting the code or refactoring it. While rewriting is easy for developers, it is very expensive. Refactoring can be done without disrupting internal and external processes. This article will guide you through the process of legacy code refactoring and highlight common pitfalls and how to avoid them.

Steps of refactoring a legacy codebase

  1. Understand: To begin, you must understand the current architecture. Take your time learning about its structure and how different parts interact with each other. Are there any patterns? Is there a reason for the complexity?

  2. Identify: Always look for ways to improve. Is it possible to make the code more readable, maintainable, readable, and scalable?

  3. Prioritize: Following your understanding and identification, you can now prioritize the areas that require your focus first. Make a plan to deal with them. Create an Impact Effort matrix (2x2) to understand the potential impact and effort required for each task.

  4. Backup: Even though version control systems are already used by the majority of engineering teams, it's a good idea to keep this in mind. Make sure your application is safely backed up in its most recent stable state before making any modifications. Both the data storage and the codebase.

  5. Test: To ensure that the refactoring has not produced any new bugs or unexpected behavior, validate it with automated tests. Write your tests against desired behavior. If there are existing tests, keep them to validate that the changes have not produced any side effects. The rule is simple: If you can test it, you can refactor it.

  6. Decompose: When broken up into small pieces, it is simpler to handle the refactoring process. You can isolate those parts and have the option to test those smaller components separately. Additionally, breaking things into tiny pieces makes it simpler to evaluate your impact. It's much simpler to understand and document the smaller part.

  7. Document: Start keeping track of the procedure. Document your understanding and any changes you make. Ensure consistency between your refactoring approach and the documentation.

  8. Repeat: Keep repeating the process until all necessary components of the application are improved. Refactoring a legacy app is not a straightforward, one-day task; it requires ongoing planning and execution.

Why would refactoring even be necessary?

Let's imagine! You have a large service called Printer that has numerous dependencies. And it wasn't always this way. The first code was written ten years ago by two college friends. It was intended to print two simple tables. The developers felt there was no need to make it complicated. They decided that they will easily create a simple service to handle those tables. There were two methods in the service: printEmployeesTable() and printDocumentsTable(). Those methods simply converted tables to pdf format. As a result, the first dependency was a service called HtmlToPdfConverter. Fast forward to today, their product is a success story. They've been adding new features and appealing to larger audiences since then. As an outcome, more printing methods were required, such as printSalariesTable(), printTranslationsTable(), and so on.

However, it wasn't that simple. They had to expand the capabilities of the Printer service because their audience was larger. Such as queues, printing in several formats, permission, and much more. You might also assume that the dependencies started growing at the same pace. When new developers joined the team, they just followed the proven procedures. They didn't want to break it. The Printer could print approximately 100 different outputs after ten years of development, each with a corresponding so-called "simple" printing method. The Printer service and the other components are tightly coupled. Even though that service still functions today, it now contains at least 10,000 lines of code and perhaps 20 or even more dependencies. Making changes is a headache because it is not readable anymore. All printing methods are all out of sync and operate in different ways. You cannot just decide to add CSV printing functionality to all tables. It's quite difficult to keep it stable and bug-free. However, this is not the end of it. Your clients want the same print functionality for a feature you're planning to release. The tricky part is that you need another dependency in order to print this new output. What will you do in this situation? Does it make sense to add a dependency and a new method to the Printer? When there are already more than 20 dependencies? Dr. Refactoring, we need your superpowers!

Knowledge to the rescue

As we already know, practically every other component highly depends on the Print service. How can we even take a small piece of it out without affecting the other components?

I'll give you a hint: There is a solution to this problem, and there will always be a solution. For this new feature, you should start with creating a test case. Once you have a failing test case, you can start the decomposition. The decorator pattern, for example, could be useful in that situation. You may make a standalone NewFeaturePrinter and decorate the good old Printer service. You may encapsulate the logic of the NewFeaturePrinter in itself this way. It can be tested in isolation. The old Printer doesn't even need to be changed, and it is unaware of the NewFeaturePrinter. At the very least, it's a much better alternative than adding more complexity to the already-existing Printer. The decorator pattern could also help you in breaking down this large service into smaller ones, more manageable pieces, especially when your components are tightly coupled. Perhaps you can resolve this issue without even using a pattern. The idea is that we should figure out a way to separate it into encapsulated chunks. This description of decomposition is also known as the Extract Method, a well-known refactoring technique.

There are many design patterns and several different techniques. Almost all of them were motivated by a specific problem. Increasing your knowledge could save you and your team a significant amount of time and money. Most businesses are unaware of their technical debt until it is too late. Refactoring should be an ongoing process for legacy applications.

Final thoughts: Keep it simple

Each team is organized differently. Finding one single way to manage this process for all is challenging. However, you can always follow the steps I defined above. Not everything needs to be refactored at once. Begin with renaming. Begin with breaking down complex services into simpler ones. Leverage design patterns. Learn from your own mistakes. Decouple and encapsulate your components. Test in isolation. Progressively get away from the complexity. And repeat.