Quite often in the software consulting world, we are confronted with requests like "Hey, we have this older system out in production, and now we would like it to do X." Those types of requests are far more common than "Hey, we have this new idea and no code, so we would like you to implement this however you want." Developers always prefer the latter and, in my experience, suggest scrapping old software and starting over with a clean slate rather than attempting to do the former. It’s too bad that the "start over" approach typically takes longer and costs more money, so the suggestion is usually dismissed. It’s happened several times during my recent projects at Skookum. Given this reality, maybe we should spend a little more time preparing strategies for working in these suboptimal coding conditions.

What is legacy code?

The software engineering community generally defines legacy code as "source code inherited from someone else or an older version of the software". It’s also been defined more succinctly as "code without tests" to narrow in on one of the reasons it’s hard to work with. No one is ever happy when they speak of legacy code. It is always used to indicate some level of dissatisfaction with the code. With that in mind, I’d like to introduce a slightly modified definition:

Legacy Code: source code that was written some time ago, and now nobody likes it anymore

If you are adding to someone else’s old code and it’s an easy and fulfilling experience, you don’t call it legacy code. You just call it code. Likewise, if I look back at code I pridefully wrote early on in my career and it doesn’t use all of the patterns and practices I’ve learned in the years since, it becomes legacy code. Just because I was the original author doesn’t mean it’s free from the struggles we associate with the term legacy code: difficult to comprehend, difficult to make changes without breaking something else, or difficult to know if you have broken something.

Getting in the right mindset

In a large legacy application, there are going to be parts of the code you can’t touch. You’ll either have parts that are controlled by different teams or parts that are so convoluted you’re sure something will break if you change even one line. (Let’s assume that the code we’re talking about doesn’t have any tests, because if somebody cared enough about the code to write tests for it, it probably wouldn’t fall under our definition.) As in life, there are some things you can change and some you cannot. It’s best to start by classifying the sections of code you’re working with into one of those categories. A lot of the problems, headaches, and delays in adding features to old applications get started by attempting to change code that is best left alone. I sometimes like to imagine that the floor is lava, and the legacy code is the floor. I may have to move a chair or two to get across the room, but I’ll do my best to stay off the floor code.

Create a doppelganger

Software is a peculiar thing. There are an infinite number of ways to create it, but as long as the UI looks the same and the performance is alright, nobody but the programmers are going to notice much difference. It could be divided into any number of classes, modules, or services. We can take advantage of that. When working on a legacy monolith code base (or if you’re reading this a few years from now, perhaps a legacy microservices code base), it wouldn’t hurt to create a separate set of files to house only the new functionality you are adding. You may not be able to write the entire application from scratch, but you can at least try to write the new feature in isolation, using new design patterns and tools that wouldn’t fit in the legacy code base.

A true story

Not long ago, I had the pleasure of adding a pretty complex new feature to a certain page in an ASP.NET WebForms site that hadn’t really been touched in the past 10 years. Based on some criteria, the page should either display data in a hierarchy view (the new way) or a flat list (the old way). We tried for days to add a hierarchy view to the existing code for the page, which was around 1000 lines already. “Maybe if we refactor this a little bit, then it would work better,” we said to ourselves in a moment of naivety. Then refactoring code A required a refactor to code B, and so on until it snowballed into an insurmountable task with no end in sight.

But what if we created a separate page that happened to look just like the old page? Then we would only need a check on page load to show old flat page or new hierarchy page. This approach opened up a world of new options. We were free from the chains of ASP.NET WebForms. We could use more JavaScript. We could write a new API for that JavaScript to use. The majority of that page was written as a new project using more up-to-date tools. Developers were happier, and the users didn’t care. They didn’t need to know. We were also able to finish the work more quickly than if we continued to try to force our way into the weeds of the legacy code.

It wasn’t perfect, though. Inevitably, you’ll have at least one touch point where your new, amazing code will have to interact with the old stuff you don’t want to change. It could be method calls, data models, a database table, whatever. Let’s move on to a way of handling those touch points with minimal annoyances.

Create a middle man

For whatever reason, legacy code seems to always come with little to no dependency injection and tight coupling everywhere. Rather than add a bunch of dependencies in your code to all of the legacy systems, why not add an adapter, or buffer, between the old and the new? Let that adapter handle the conversion process between reality and your expectations. Then in the event that you can one day rewrite that old code the way you want it, the rest of the application is all ready to go. You’ll only have to remove the adapter.

A second true story

More recently, we worked on a project where we had to use an API from an older system. As you might expect, it’s interface wasn’t exactly how it would have looked if we were writing it. The main complaint was that it required three sequential requests to upload a file to the server. That felt a little unnecessary to everyone, but the API was owned by another team, and other applications were already using it as is. We couldn’t improve the API at that time, but we could limit the spread of poor code quality into our application. We knew the interface we wished the API had: send a file, get back a URL. Thus, the MorePleasantFileUploadService was born.

class MorePleasantFileUploadService { uploadFile(file, callback, errorCallback) { // do whatever you gotta do to work with the legacy code… if (didItWork) { callback(fileUrl); } else { errorCallback(); } } }

This hid the difficulties with the legacy API inside a single class. The rest of our code maintained its innocence. The MorePleasantFileUploadService is like shoving all of your dirty clothes under the bed when your parents come to visit. It helps keep everyone at ease.

A closing word of warning

Nobody writes legacy code on purpose. Sometimes it happens solely because programming languages and frameworks rise to prominence and fade into obscurity over time (and that’s okay), but sometimes it happens when developers are only concerned with the present. Thoughts like “Oh, one more if/else block won’t hurt.” or “I could get this done in an hour if I add a couple more dependencies to that class” add up over time, and before you know it, what was once a neat and orderly code base is going to be a thorn in the side of whoever works on it after you.

You may not always get to rewrite old software from scratch, but that doesn’t mean you are doomed to be frustrated by the mistakes of the past. One step at a time, it is possible to start the process of bringing old legacy code up to your new standards. It just takes more patience.