How to manage fragile legacy code?

How to manage fragile legacy code?


9 min read

Also available in 🇪🇸Spanish & 🇵🇱Polish

Welcome to the real world! After doing all the over-simplified learning examples, you’ve started your first IT job. It’s pretty likely that after doing a few warm-up tasks, you’ll realize that there is some nasty old code in the project you’re working on. If you’re lucky, there is someone on your team who tries to protect you from venturing too much into that dark area, but you can be sure it will not last forever. You will need to confront the reality at some point—a lot of your day-to-day job will consist of maintaining a legacy codebase. Let’s see how you can make this experience as manageable as possible.

Skim the code

Get the code, and dive into it a little bit. The existing code is likely to be confusing, the files likely too big, and everything can feel like a mess. At first, you could focus on the file structure to find patterns. In a project with a long history and little focus on consistency, you’re likely to find competing designs used in a seemingly random way. In this case, just try to figure out how bad it was and leave the systematization for later.

README update

After going through the code, let’s assess the state of the documentation. There should be some kind of README file. Maybe it’s criminally outdated, but at least it mentions the ideas used in creating the codebase. You can try to update the info as you find some obsolete parts. A good place to start is to integrate what you received in emails and other communication about the project and make sure it’s reflected and linked from the README. Moreover, make sure you update it regularly so that the next person in the project has an easier start.

Figure out the building process

I recently worked through an Angular upgrade course that received its last update three years ago. The first videos were on Angular 4—a version so old that it has compatibility issues with the latest Node.js and chipset architectures. I went through a cascade of forced upgrades before I could build it on my machines—and that was just in a small, example project. A project you inherit in the real world will probably be in a much worse state.

Hopefully, all you need to build the project successfully will be

  1. covered by README or other documentation,
  2. still up to date, and
  3. working as expected.

Most likely, your project will fail to meet those points along the way, leaving you to figure it out on your own. If it’s failing, you can search for the error and see what’s causing it.

If there is nothing helpful in the documentation, the next place to check is package.json. There can be some build-like command among ”scripts”. In the worst case, you can search for any traces of build tools that were popular in the past and engage in a kind of JavaScript archeology:

  • grunt
  • gulp
  • webpack
  • browserify
  • require.js

Image description

And the deployment

That can be fun as well. Not knowing where your systems are running is pretty frustrating: it makes you worry about what might happen if they break. We had an internal chatbot, and we couldn’t figure out where it lived for a few months. This information should be more easily available for more mission-critical systems, but those things often take more time than expected.

Finding out all the details about your production deployment can take a lot of time. You could be stopped by not having the right user, missing credentials for some areas, and having to double-check everything to avoid breaking things. Congratulations! You can make a code change on your machine that makes it to production. Your work can have an impact.

Investigate the existing quality assurance (QA) measures:

Similar to what you did for the build, try finding whatever is out there for

  • Integration tests,
  • Unit tests, and
  • Linting.

If not documented and not defined in package.json’s scripts, you can search for some tooling that was common in the past.

  • Integration tests:
    • protractor
    • selenium
  • Unit tests:
    • jasmine
    • mocha
    • sinon
  • Linting:
    • jslint
    • jshint
    • tslint

If there are traces of any automated QA measures set up in the past, try reusing them as much as possible. There can be real value in all the stuff past developers set up, and it would be a shame to throw it all out.

Establish a smoke test

If you are restoring an old engine, on the first try, you’ll start it without any load to see if it works without burning. This is called a smoke test—all is well, as long as we see no smoke. The same goes with legacy code: when you have nothing with which to test the application thoroughly, you can at least build it and see if the application starts up correctly. Until you can run more sophisticated tests automatically, you’re better off limiting yourself to straightforward changes and use this same basic procedure to make sure all is working as expected.

Image description

Start with tiny changes

The project revival process goes pretty well; you are now ready to start performing code changes. Start small, with things that nobody will notice. You can

  • update the patch version of some library,
  • rename some local variables, or
  • refactor out some annoying anti-pattern in one place.

Remember that your goal is to make a change that is almost sure to have no side effects.

Deploy to production

Build the code with your trivial changes, and deploy it to all the environments up to the production. Your goal is to make an example for this restored process—getting code changes from your head to the customers’ machines. It’s challenging enough on its own; that’s why we kept the scope of our changes to a minimum. Your goal is to give yourself and the stakeholders confidence in the process.

Add integration tests

Most likely, you will have to build integration tests from scratch. I mean those tests that check that everything’s integrated and works together. Another name is end-to-end (e2e) because you often test both frontend and backend together. You should start here because it doesn’t matter if you calculate the VAT correctly if the application fails when you start a new transaction. At first, it can make more sense to develop simple tests for many pages instead of precise tests for only a few. A test as simple as visiting a route and checking if the key interface elements are still present can provide a lot of value—this catches instances when some page breaks completely.

Image description

Set up and start adding unit tests

Once you know that the application is starting as expected, you can go into the details and see if all the units—classes, functions, etc.—are working as expected. You could test them from the level of the user interface, but end-to-end tests are:

  • slow to write – getting the correct data in the right place takes a lot of time
  • slow to run – having a database, backend, and browser running uses a lot of resources

Instead of cramming all imaginable edge cases into your integration setup, you want to have a separate test set up for more nitty-gritty details. You want unit tests. They:

  • are orders of magnitude faster to run than e2e
  • are disconnected from backend and database
  • allows testing atomic parts of your application, regardless of surrounding code and workflows

Want to learn more? Here you can find my reason to write unit tests.

Lint code

Linters are static analyses of the code meant to point to the sources of common issues. It’s nice to have a regular check that goes through your code and tells you that all is good here. eslint, a popular linter for JavaScript and TypeScript, allows for a lot of configuration. You can set the config to catch only the most problematic issues, fix the whole codebase, and integrate it with your code editor. To keep getting more value from the setup, you can continue iterating over the config—turning on or off different rules and adding framework-specific plugins. In this way, you will be able to make your code more likely to improve with time; and at the same time, every step will be small and won’t take you too much time at once.

Enforce coding style

Sticking to the same formatting makes it easier to read the code: in this way, there are no weirdly formatted places that attract your attention for the wrong reason. Consistency in the codebase brings a sense of order and makes it easier to trust in the project’s current state. Coding style can generate a lot of heated discussions. I don’t care that much about particular formatting; I just want to do the same, no matter who and when wrote the code, and I like the style to be applied automatically. Luckily, right now we have opinionated tools, such as prettier, that can format entire projects in a moment and leave only a few options to discuss inside your team. So you can outsource both the formatting itself and the endless discussions about it to an external tool!

Go through easy lib updates

Now, let’s do some final warm-up tasks. You can try to update some dependency from one patch version to a newer one—for example, from version 1.2.3 to 1.2.4. Your code is probably using ancient versions of every third-party library it uses. Upgrading them will be a lot of work, and it’s a good investment to do so: new versions typically have some new features, security fixes, and more materials available online. Even a patch upgrade is a non-trivial change, and with all the work we have done here, we should expect one of two results:

  • things fail after the update but are caught by one of our verification layers, or
  • everything works fine—both in testing and then on the production after deployment.

Make sure you don’t try to rush those updates! Do one at the time, release, and wait for feedback. Remember, you are teaching people to trust your changes, and the last thing you need is some regression.

Make the most minor, most straightforward user-facing improvement

So far, your work has been almost invisible to the users—unless you managed to break the production at some point. With all the infrastructure in place, you are ready to deliver some value! Pick the smallest and simplest issue you can find—a typo on some page or a missing margin on a button. Your goal is to demonstrate to yourself and to others that you can fix issues and deliver code changes without breaking anything else. Don’t mess it up by trying to do something impressive.

Set up continuous integration (CI)

More change requests will come if the project is really coming back to life. It will make sense to set up continuous integration in that case. If you’re interested in learning more, let me know in the poll:

What’s next?

Congratulations! You are the new go-to person for the legacy code. Enjoy your newly acquired job security— the work has to be done, and there is no queue of people willing to do it. It can be an excellent time to double-check whether your compensation is up to the market and covers the value you provide to the company—read more here.