Working Effectively With Legacy Code—How To Cooperate When Refactoring

Michał Nowicki, Marta Gajowczyk02/29/2024

Working Effectively With Legacy Code — How Refactoring Let Us Escape The Black Hole

I’m pretty much sure that sooner or later every developer finds themselves working on a project that makes them face the need to refactor legacy code. For those lucky ones who didn't experience it firsthand—legacy code is code with a past. It’s code that someone else wrote and you inherit it for further work. But unlike your family inheritance, legacy code that needs refactoring is not exactly something to look forward to. Why?

First of all, this legacy code was passed multiple times from one developer’s hands into another’s, meaning there might be some chaos and incoherence involved (and usually is). The application core or even business concept itself may have changed substantially over the years. More often than not, your predecessors didn’t have enough time for proper maintenance, like keeping documentation up-to-date, refactoring, or simply stopping for a while to analyze the technology debt the project may have been accruing.

And now, YOU have to deal with all of it. Effectively.

The black hole of legacy code refactoring

How our team looks every time we face legacy code

In our case, one such moment of truth happened almost a year ago, back when we were working on an HR Web app. First commits date back to the year 2015. Early on, the application was supposed to be rather small and maintained only by the product owner. And that’s very often the case—apps start out small and then get bigger and bigger as the team grows. If you don’t pay attention, your code might get very messy, very quickly. And that’s exactly what happened there.

In a situation like this, everyone has to decide whether to build a new version of the app from scratch or refactor it. We went with refactoring because it’s our preferred approach. As the topic is a bit touchy, especially for your client, you want to be well-prepared ahead of time, believe me on that.

We learned our lesson on working effectively with legacy code and I decided to share our story. Best case scenario, we’ll save you some time thinking about the right solution, help you avoid some mistakes, and maybe even convince you to give refactoring a shot if you’re not exactly a believer. Worst case scenario? Well, you’ll definitely have some fun reading about how we were thrown into the abyss and slowly clawed our way back out into the light.

Commencing Countdown

1. Communication

The first problem we encountered was something that isn’t obvious at first, either for the developers or the client. But you should be aware of it as it gets tricky down the road if left neglected. I’m talking about the language that we use to describe and discuss features and business logic with the client.

There are some terms (class names, feature names, frontend names) that describe the same thing but are called differently. For example, a feature that a website user perceives as a Spaceship can be called TinCan on the backend side, while our client would refer to it as Rocket.

It makes every discussion with the team and client confusing and ambiguous, and forces both parties to constantly switch contexts. Consequently, the team wastes time, the client wastes time, and it makes everyone irritable and annoyed.

Speaking the same language

In an ideal world, there would be a single name for a single feature provided at the beginning of the project, and everyone involved would use the same language in the codebase and the UI. But the world is far from ideal, so we move on to different options.

The less perfect, but more feasible solution is to change the backend codebase to use same language as the UI. It can be time- and resource-intensive, but will definitely pay off later.

The easiest, but the most imperfect approach, entails creating some sort of document with feature synonyms that would allow everyone to look up terms when needed and always be on the same page.

You get the idea. How, then, should you approach the issue when the codebase is already written and there is nothing like a ubiquitous language available?

Well, the best you can do is convince everyone on your team and the client to embrace event storming before moving on to bigger features and keep your fingers crossed that everyone was listening. That’s what we did and it turned out well. We had no more miscommunication events.

2. Documentation

The next thing that we struggled with was almost no up-to-date documentation—because of the type of project it was. It was a struggling startup that wanted to deliver a lot of features in a short period of time. Maintaining documentation was always put on the backburner despite the fact that the business logic was quite complicated with a number of specific cases.

One of the first things we had to do was change the very core logic of the application. And the lack of requisite records made it very difficult for us to notice which parts of the codebase were still useful and which weren’t.

You can also imagine, that when you have non-existent documentation and you’re new to the project, the onboarding process is much longer. You need to figure it all out by yourself. A few months in, chances are you’ll still be encountering blocks of code that you don’t need anymore. And all of this can be avoided with proper documentation.

Being on the same page

Ironically, the first thing we did was not drafting the documentation. We started with refactoring the code itself by recognizing crucial use cases and dividing them into separate components/services (name it as you like).

After that, documentation was easier to write because we already had a clearer view of the situation and simpler, more descriptive code that was tasked with only one thing—handling that specific use case.

To fully understand what I mean let’s look at this example (very simplified, but should be enough to understand the core concept):

 
class IntoTheSpaceISay
  attr_reader :money_limit
  
  def self.call(money_limit)
    new(money_limit).perform
  end

  def initialize(money_limit)
    @money_limit = money_limit
  end

  def perform
    validate_money_limit
    limit_list = calculate_max_cost_for_each_part
    deals = search_shops_for_best_deals(limit_list)
    parts = buy_parts(deals) if every_part_can_be_bought
    validate_parts_presence(parts)
    unpacked_parts = unpack_parts(parts)
    read_instructions(unpacked_parts)
    build_spaceship(unpacked_parts)
  end
  
  private
  # Imagine 200+ lines from those methods here \/
end

As we can see, the IntoTheSpaceISay class receives the money_limit parameter and, based on that, tries to do some magic in the call method.

On first glance, we don’t know what is or what should be happening. We may get an inkling, however, after stumbling onto the buy_parts method and the build_spaceship method. From there, maybe we can get some sense of what’s what and unravel the whole thing.

Understanding, documenting, and changing this will be a pain because of low readability, complexity, and clutter since everything is in one place. We don’t have anything that would split this flow into higher concepts.

Let’s look at an example featuring use cases:


class IntoTheSpaceISay
  attr_reader :money_limit
  
  def self.call(money_limit)
    new(money_limit).perform
  end

  def initialize(money_limit)
    @money_limit = money_limit
  end

  def perform
    parts = UseCase::Spaceship::BuyParts.call(money_limit)
    UseCase::Spaceship::Build.call(parts)
  end
end

Now, it’s rather easy to say what the responsibilities of the IntoTheSpaceISay class are.

The call method calls two use cases, one for buying parts and one for building the ship from those parts.

Since everything is split up, we can create better documentation, understand what is happening, and pinpoint the bug source faster.

We all know that maintaining documentation up-to-date can be a pain in the neck and there’s always something more important to do. That’s why we started with a simple documentation that’s easy to update. User stories was the type of documentation that met our expectations; although it focuses on real user experience rather than on the codebase, such an approach still gives you a better picture of what the application should do and what the user should expect from it.

3. Refactoring legacy code

So we have code. But it’s barely passable so we need to do something about it before we dive straight into the fat controllers and even fatter models where we need to provide new features.

Okay, so what do we do?

Letting your client know that you need to spend some time and THEIR money on refactoring without delivering any new functionalities or even fixing a bug can definitely be a bit of a challenge.

And this challenge depends on the following factors:

  • deadlines on the client side,
  • a current feature with great business value that can’t be postponed,
  • limited budget for the current time period,
  • the client simply not seeing the value in this kind of work.

Noticing the value of clean code can take time, so be prepared to wait as sometimes there’s no other way, so—“Remember, patience, my young padawan, patience.”

Tidying up the code

Lucky for us, in this case we were able to omit the inform-and-wait part of the process. Our client had already created a few apps in the past and also had a team of specialists on their side, so we didn’t exactly have to convince anyone to agree to tidying up the legacy code a little bit. They knew that it was our responsibility and were committing to the product for the long haul—which was awesome!

Sadly, not every code Jedi is lucky enough to work with someone who is already “on their side.” Not every client has past experience in working with developers who, for one weird reason or another, want to do something that “doesn’t bring any value, DUUH!”, at least not at first glance.

What you can (and should) do is to roll a skill check for convincing, a skill that you obviously have been patiently honing for last few years just for this moment, yeah…

Jokes aside, you will need a little bit of persuasion, supported by good arguments, time, and a client that has no problem with you delaying the delivery of new features. Features that seemed pretty easy and quickly shippable on paper.

Below is a list of the advantages offered by refactoring. Hopefully they’ll help you push through the convincing part:

  • faster delivery of new features in the future,
  • easier reasoning about how a new feature will be adapted to the current codebase,
  • better expression of business logic in the code which equals easier communication,
  • easier and faster codebase onboarding for new team members,
  • we know that it’s 2018 and these are no longer the dark times of yore but:
    • IF YOU HAVE TESTS and a CI tool in your workflow, then better architecture and properly adjusted tests could speed up the automated testing process. In the long run, you spend less time waiting on green dots to appear,
    • performance can get better, and your clients’ clients will love that!
    • additional motivation for coders ;)

Remember, however—it’s all relative. Some clients will know the value behind refactoring, others will be fine with it after you provide them with proper arguments, whereas still others will, unfortunately, need to experience all the disadvantages that keep popping up when you decide to go with shortcuts and a dirty codebase.

Engines on

If you’re facing similar problems with a big, messy application on your plate, and see the need for refactoring, you’re probably wondering how to approach and kick-off the whole process.

Well, truth is that the best approach is being straightforward from the beginning. Don’t sneak around and refactor without communicating your intentions. Give the team true metrics and let them know that the process yields tremendous value in the long run and will most definitely pay off.

Such an approach will result in faster delivery of new features and make introducing changes to the logic as well as refactoring legacy code itself a lot less painful. Adding a new button to the UI will take you a few hours instead of a few days. This, in turn, means simpler and smoother onboarding of new team members.

It’s safer and leads to huge performance boosts in the application. It’s less prone to bugs and failures. There’s no reason to shy away from it and your client should know it. Effective refactoring has helped us escape the black hole of legacy code and can help you, too.

Cta image
Michał Nowicki, Marta Gajowczyk avatar
Michał Nowicki, Marta Gajowczyk