Rescuing a legacy Angular application
Story of how we reliably migrated a legacy Angular application in a short time.
Do you believe it is possible to upgrade 7 major versions in a mid-sized Angular application reliably in just 2 weeks? Here is how we did it:
Our team became responsible for a legacy application. It was a novelty to us, but it had been running for a while already. Since its initial release, the application fulfilled its purpose very well, so little further investment was made. This brought some challenges because it became legacy with time. Any change, bug fix, or vulnerability mitigation felt like an unacceptably expensive task to perform. So, enhancing the application to bring new value was very hard.
This is no unusual situation for software developers. Most organizations keep legacy projects that provide essential functionality. Still, due to business and technical decisions, their maintainability can decrease to a point where it makes sense to plan for its enhancement. Such a plan sounds like a big mission. Days of planning, weeks of refactoring, and validation. Increasing the maintainability of a medium-sized project can take months. It is expensive in development time and brings the risk of introducing bugs in the new code. One of the key actions to improve maintainability is to migrate the used technologies to state-of-the-art versions.
1. Gain Awareness
Give me six hours to chop down a tree, and I will spend the first four sharpening the axe.
Abraham Lincoln
Software Engineering moved away from excessive upfront planning. We tend to use methodologies where feedback cycles are short. Working code is created quickly to validate and evolve ideas. Nevertheless, jumping into a complex task requires understanding the context and sketching a plan to avoid suboptimal results and wasted effort. Therefore, we invested a couple of days in learning about the application. The goal was to become more accurate at estimating the effort and effect of each possible task.
The first thing we did was to gain some insights into the application. The application was:
- built by a team we can no longer contact
- served with Server-Side Rendering technology
- written in Angular 10
- styled with Bootstrap
Knowing exactly what the app does is necessary to ensure it continues to work as expected. The best way we found was to use the route configuration files as an entry point to explore the whole functionality and build a description of its features in a confluence page.
2. Set your priorities
The Pareto principle (also known as the 80/20 rule, the law of the vital few, and the principle of factor sparsity) states that for many outcomes, roughly 80% of consequences come from 20% of causes (the “vital few”).
Pareto principle
As engineers, we have an optimization mindset. We want to maximize the outcome out of our time and effort. To do that, it is necessary to identify the actions that contribute the most to the final result. The priority is to get started with those “vital few” actions.
The most impactful task was to upgrade to a modern Angular version. That would unlock everything else, as it would significantly improve the application's maintainability.
Other issues that would follow are:
Security issues. They are always among our top priorities due to their potential harm. So, every major vulnerability mitigation must be high on our priorities list.
Broken features. Anything that is not working as intended needs the help of a kind developer.
Developer Experience. Migrating styles and testing to modern technologies, as well as applying best practices to the code, are examples of significant improvements.
3. Execute
3.1 Angular 10.x to 17.x
Angular recommends migrating versions one by one. They provide a very useful set of instructions for each version step. That is fine for two or three major version changes. However, our application was at a point where that would have taken too many intermediate steps. Imagine refactoring and validating the whole application seven times, discarding most of the effort of the intermediate steps.
Our drastic solution was to start a modern greenfield SSR angular project. This is done in a branch of the same repositoriy so we do not lose all the project history and context.
The key effort for optimization lies here:
The main value is migrating the Angular version, so working on anything not strictly necessary for this is losing focus on our priority.
We were tempted to introduce our beloved Tailwind, Jest, and Cypress already in this greenfield project. But was that absolutely necessary to migrate the Angular version? No. Deferred!
The mission was to transplant the application from one Angular shell to a more modern one. If it had been possible to change nothing in the app logic, we would have done so. The only intended major change was to update the “Angular shell”. So, initially, we tried to keep all other dependencies the same.
Incompatible dependencies
Some dependencies refused to work with a modern Angular version. For example, ng-toolkit/universal
, a library that replaced browser API (mainly used for window and localStorage) when in the server, could not stand the upgrade. There is no trustworthy successor for this dependency, and the best practice seems to be refactoring the code to get rid of the dependency. This refactoring task exemplifies the necessary adaptations we had to make in the application logic.
3.2 Broken Styles
No plan of operations reaches with any certainty beyond the first encounter with the enemy's main force.
Helmuth von Moltke
Our new Angular 17 app was alive! It could not be deployed like this, though. Styles were totally broken, and this was not planned. The latest compatible version of Bootstrap broke the User Interface. The last version that kept UI as expected had a peer dependency restriction. It was time to make a compromise. We were delighted with the peace of mind of respecting all npm peer dependency restrictions. But here came the need to make an exception: the effort to migrate all the styles to Tailwind or to the latest bootstrap would have eaten all our available time for the migration. A follow-up story was added into our JIRA backlog to be picked up later.
3.3 Keep patching
Other adjustments followed the examples of the Browser API dependency or the styles. We always kept this in mind: What is the smallest possible change we can make to bring this application to fulfill production behavior? Do not fall into a rabbit hole to optimize something that is not blocking the functionality.
4. Tests
Jasmine tests were in place to validate the application at unit and integration level. We found that migrating them to Jest was easy. Most changes were about changing the spies syntax and adjusting the assertions to be more idiomatic. Having these tests passing helped us validate that the migrated application kept working as expected.
Some basic Protractor E2E tests were in the project as well. Unfortunately, they were not able to run because they lacked proper maintenance. To keep some minimal UI test coverage we introduced Playwright and configured GitHub Actions to execute it in the pipeline with a minimum content.
5. Conclusion
- Invest time to explore the application status and functionality so you can identify the changes with the lowest effort and most impact.
- Focus on the task that improves maintainability the most (Typically migrating Angular version). Do not spend time on anything that is not absolutely necessary to achieve this.
- Make any necessary patches so the application keeps its previous functionality, look & feel, and behavior.
After two weeks of work, we unlocked the capability to work effectively on this application in the team. New fixes and features can be implemented safely and faster, and the developer experience is greatly enhanced. Vulnerabilities coming from old dependencies are solved, improving security. Our team now holds proper ownership of the app!
More on Angular migration: Lessons learned from a major Angular migration
Published by Fabio Iglesias Rodríguez
Visit author page