How to conquer legacy code and not die trying
As a software engineer, I know how frustrating it can be to work with legacy code, especially when your client has no idea of the level of technical debt you are inheriting, and wants you to deliver bug fixes and new features as soon as possible. If you’re as passionate about software development as I am, you’re supposed to enjoy it, not hate it. That’s why I’m writing this blog post: To share my experience and a key piece of advice about how to deal with it.
The most common issues of working with legacy code are:
- Having no tests at all, or no useful tests.
- Outdated dependencies.
- Poor software architecture.
- Technical debt.
- Lack of documentation.
Here are some recommendations about how to deal with it and not to die in the attempt.
After performing this assessment, you’ll be aware of all the risks you’re taking (or inheriting). In the end, you’ll have to decide how comfortable you are with the given risks. Therefore it’s super important to know the current state of the project and to be aware of what to expect when it’s time to get your hands on the code.
The goal of this assessment is to learn as much as possible of the current codebase. My recommendation is to focus on the following:
- Documentation: Unfortunately, most of the time the only documentation that you might find in an existing project is the README file, and even worse, this file is usually not up to date. Look for architecture diagrams, wikis, or any other documentation that can help you to understand the codebase at a glance.
- Test coverage: Speaking of documentation, tests are considered the best code documentation. You should check for the current test coverage, but more importantly, check the quality of the tests. Sometimes tests check against specific text instead of testing the business logic that drives that text to be displayed. If there are good quality tests, you should be able to have a better sense of the business logic, and moreover, the current architecture.
- Dependencies: Having a ton of dependencies is not a good sign. I always try to avoid adding a new dependency unless it’s strictly necessary, or the benefits of said dependency exceed the cost of making it happen. Check how outdated dependencies are and how difficult it would be to do upgrades. Pay extra attention when it comes to deprecated versions and you need to do major upgrades.
- Deployment process: A new feature, bug fix, or improvement is not considered done until it reaches the production environment. You need to understand what the deployment process is since you have to take this into account while giving estimations.
- Backlog: After getting a little bit familiar with the codebase, you need to know what’s about to come. You might be asked to add a particular feature that might not be that easy to add given the current architecture or the version of the dependencies implemented. The value of knowing the backlog beforehand is to raise any warning flags in the early stages so that you can plan.After going through each of the above points, you should be able to communicate any risks to your client. Set clear expectations and decide whether to move forward or not based on your discoveries.
If the test coverage isn’t the best, you should ask to work on adding tests before adding new features. You need to be confident enough that you’re not adding new bugs while changing the codebase, and the only way to be sure is to have a proper test suite. Good test coverage is your insurance as a developer.
Refactor on the go
I know that you might be tempted to do a major refactor as soon as you start touching the codebase; however, based on my experience, that is not always the best option. A big refactor can take forever; it’s like a chain reaction. You start with a few files or lines of code, which then scales so quickly that you’re suddenly in a situation where you’ve already refactored hundreds of files with no end in sight.
A better option is to do small refactors on the go. Refactor the code you’re touching based on the features you’re working on.
The approach that I like to follow in this case is one of the SOLID principles, Open-Closed principle, which suggests that a class should be open for extension and closed for modifications. If you can’t extend an existing class or file, then create a new one following this principle, and use it only when it’s required. Avoid changing existing implementations since it can scale quickly, and you might get lost in the refactoring instead of focusing on delivering the feature.
Dealing with legacy code shouldn’t be that painful. It depends on your approach to working with it.
Here are the things that you should do before starting the feature development:
- Read the existing codebase.
- Analyze the current backlog.
- Add tests until the point you feel confident enough.
- Set clear expectations to your client.
Once you’ve already started the development process, these are the things you should bear in mind:
- Avoid major refactors; instead, do refactor on the go.
- Add tests to any single feature or bug-fix you work on.
Let me know your thoughts in the comments section. If you have any other suggestions about how to deal with legacy code, feel free to share it with us.