1. Introduction

As developers, we hear quite often that someone refactored their code. Most probably, we did refactoring many times, too.

It’s such a common term that we usually don’t even think about what it means. Unfortunately, some people don’t have a clear understanding of its true meaning. Therefore, they label activities as refactoring, which have very little to do with it.

In this tutorial, we’ll clear the air around refactoring.

2. What’s Refactoring?

Martin Fowler has an excellent book called Refactoring. Because we don’t want to be smarter than him, we’ll stick to the definition he provides:

Refactoring is the process of changing a software system in a way that does not alter the external behavior of the code yet improves its internal structure. It is a disciplined way to clean up code that minimizes the chances of introducing bugs. In essence, when you refactor, you are improving the design of the code after it has been written.

This quote describes very well the essence of refactoring. Let’s talk about its key points.

2.1. Behavior

The most important characteristic is that refactoring “does not alter the external behavior of the code”. It means that we don’t add or remove any features nor change how they work. For the same input, the software produces the same output. Therefore, from the user’s perspective, the software didn’t change. Except maybe the performance, but let’s put that aside for now. To clarify: the user can be the end-user (who uses the software) or another developer (for example, when we write an API or a low-level component).

Note that we didn’t say anything about the internal behavior. It’s because it doesn’t matter from an external point of view. Let’s say we need a function that removes double spaces from a string. We could implement it by finding and replacing using regular expressions or implementing a simple state machine and a loop. If the function removes double spaces, it fulfills its purpose. Therefore, the implementation details don’t matter.

2.2. Structure

What’s the point of refactoring if it doesn’t change the code’s functionality? Refactoring software “improves its internal structure”. “It is a disciplined way to clean up code that minimizes the chances of introducing bugs.” It doesn’t provide direct value to the end-user. Then why do we even bother? Again, Martin Fowler says it best: “Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”

In other words: it impacts maintainability. For example, it’ll be faster to find and fix bugs or to add new features. Therefore, it transitively provides value to the end-user by making the software easier to modify. Also, it means less headache and burnout for developers.

2.3. Timing

Last but not least, “when you refactor, you are improving the design of the code after it has been written”. The essence of this quote is that first, we should make the software work. It doesn’t matter how well-written the code is if it doesn’t fulfill the requirements. Once we’re there, we can improve its maintainability.

3. What Isn’t Refactoring?

Now that we defined refactoring, it’s easier to identify activities that don’t fulfill this definition.

For example, we can hear this sentence quite often: “I refactored the code and implemented X feature.”

If the developer meant that she changed the structure and added new behavior simultaneously, the previous statement is false. We should remember the definition: refactoring doesn’t change the externally observable behavior. A new feature changes it by definition.

Restructuring and adding new behavior to the software should be orthogonal activities. Of course, sometimes we need to restructure the code so we can change the behavior. For example, when we realize that a class isn’t enough, we need an inheritance hierarchy instead. First, we extract the common things to a class or interface. This doesn’t change the behavior, only the structure. Next, we add the second class and make it part of the class hierarchy. This doesn’t change the existing structure but adds a new element.

The sentence above could be only right if she did these activities iteratively. No new capability while restructuring. No restructuring when adding new behavior.

4. Prerequisites

We identified refactoring’s three major characteristics:

  1. It doesn’t change external behavior
  2. Changes the code’s internal structure
  3. It’s done after the code fulfills the requirements

How can we be sure of the 3rd point? Also, how can we enforce the 1st? Fortunately, there’s a simple solution for both: tests.

We should write automated tests to assert every business case. When all of them are green, we know that our software fulfills the requirements. Therefore, we’re ready to refactor. Note that tests can also be successful because they’re missing. Alternatively, they may be faulty because they’re always green. But let’s assume none of those are the case, and we have tests, which are working correctly.

What about the 1st point? Since we covered the business cases with tests, it’s straightforward: we run the entire test suite after every refactoring step. If every test is green, we didn’t change the behavior. If some of them break, there are two possibilities.

The first is that we changed the external interface during refactoring and forgot to modify it in the tests. For example, renamed a class or removed a function’s argument. In this case, we have to alter the test code and rerun the tests. We should never forget to maintain the tests when we change the code.

The second case is that during restructuring, we accidentally changed the behavior, too. Since our tests clearly state which scenarios are breaking, it should be easy to fix the code.

Note that we can switch between adding new features and refactoring as often as we want. We don’t have to (and shouldn’t) wait for the software to fulfill all of the requirements before we start refactoring. The important thing is that tests should cover that part we want to refactor.

5. Examples

In this section, we’ll see a few examples of refactorings. Note that there’s an enormous amount of refactoring techniques. Our only purpose with these little examples is to make it a bit easier to imagine.

Refactoring by Martin Fowler contains a comprehensive list of refactoring techniques. Also, refactoring.guru has a nice refactoring catalog, too.

5.1. A (Seemingly) Simple Case

One of the simplest refactorings is renaming a variable. For example, when our web application starts, we want to change the title and log that the application started. We implemented it with the following JavaScript code:

title = 'Refactoring';

function logStart() {
  message = 'started';
  console.log(message);
}

logStart();
document.title = title;

It works as expected. However, we decide that title would be a more descriptive name for the variable inside logStart(). The modified function looks like this:

function logStart() {
  title = 'started';
  console.log(title);
}

However, the code doesn’t do the same as before: the title will be “started” instead of “Refactoring”. The reason is simple. We already defined a variable in the global scope with the name title. We didn’t declare it in the logStart() function. Therefore, we overwrite the value of the variable.

The point is: even in the simplest cases, it’s important to run the tests to prevent unforeseen side effects.

5.2. A More Complex Case

Let’s say we implemented the square root calculation in a very efficient way. However, we still need to verify whether we got a valid input. We came up with this solution:

function sqrt(value) {
  if (typeof value !== 'number' || value < 0) {
    return NaN;
  }

  // the magic happens here
}

However, we’re not satisfied with this. The typeof part of the validation condition could be more readable. We decide to create a function that contains the check, and we call it from the condition:

function sqrt(value) {
  if (isNotNumber(value) || value < 0) {
    return NaN;
  }

  // the magic happens here
}

function isNotNumber(value) {
  return typeof value !== 'number';
}

This refactoring is called extract method.

Note that we could have used the already existing Number.isNaN() instead of !isNumber(value). We wanted to provide an easily understandable example.

5.3. A Somewhat Advanced Case

Let’s say we have the following Java class:

class Animal {
  static final int TYPE_DOG = 1;
  static final int TYPE_CAT = 2;
  
  int type;
  
  void makeSound() {
    switch (type) {
      case TYPE_DOG:
        System.out.println("woof");
        break;
      case TYPE_CAT:
        System.out.println("meow");
        break;
    }
  }
}

However, we don’t like this implementation. When we introduce new responsibilities to the class, we have to duplicate the switch statement in other methods, too. Also, if we want to model other animals, we have to add new cases to every switch statement. This makes the code fragile.

Instead, we decide to get rid of the type code and create separate subclasses for each animal:

interface Animal {
  void makeSound();
}

class Dog implements Animal {
  @Override
  void makeSound() {
    System.out.println("woof");
  }
}

class Cat implements Animal {
  @Override
  void makeSound() {
    System.out.println("meow");
  }
}

If we want to add a new responsibility, we add a method to the Animal interface. Until we don’t implement it in every subclass, the code won’t compile. If we want to add a new animal, we create a class that implements the Animal interface. Again, we’ll get a compilation error until we implement every method.

Why do we like compilation errors? Because that way we can’t miss the fact that we forgot to handle a case. Of course, it doesn’t matter if we cover all possible cases with tests. But we know that more often than not, our tests don’t cover everything.

This refactoring is called replace typecode with subclasses.

6. What Should We Refactor?

6.1. Identifying Problems

Until this point, we defined refactoring (what is it), saw its prerequisites (when to do it), and saw three simple examples (how to do it). But how do we know what should we refactor?

Every time we see a code that’s readability could be improved, it’s time to refactor. But it’s still not an exact definition. We won’t be able to provide one. However, we can identify common signs in the code that we should refactor. Sometimes these signs almost scream that we should do something. For example, consider this code:

function calculatePrice(user, product, amount) {
  // loyalty discount
  const a = user.orders.length > 10 ? 0.9 : 1;
  // amount discount
  const b = amount > 100 ? 0.9 : 1;
  // discounted price
  const c = product.price * a * b;

  return c * amount;
}

Instead of writing comments above the variables, we should give them meaningful names:

function calculatePrice(user, product, amount) {
  const loyaltyDiscount = user.orders.length > 10 ? 0.9 : 1;
  const amountDiscount = amount > 100 ? 0.9 : 1;
  const discountedPrice = product.price * loyaltyDiscount * amountDiscount;

  return discountedPrice * amount;
}

Sometimes it’s less obvious, like with the Animal class in the previous section.

Nevertheless, when we look at problematic code, we can feel that something’s not right. It smells. Indeed, we call those signs code smells. Besides code smells, Uncle Bob also calls them heuristics. He provides a list of code smells and heuristics in his excellent book, Clean Code.

6.2. Performance

We briefly mentioned application performance before. Micro-optimized code is usually much harder to read. But if it’s a critical optimization, we should only refactor it to make it more readable when it doesn’t decrease the performance.

On the other hand, it’s much easier to optimize readable code. It shouldn’t be a surprise since if we can’t understand what the code does, we can’t optimize it either.

Many refactorings introduce a new level of abstraction. For example, a new method call, a new class, or an entire hierarchy of classes. These abstractions come with a computational cost. Therefore, the application’s performance slightly decreases. However, this decrease is so insignificant that we don’t even notice it.

Even if the impact is noticeable, we usually go with the slower but more readable code. The reason is that usually, it’s much cheaper to buy hardware with more performance than the developer’s additional cost because the code is harder to maintain.

Of course, there’re exceptional cases. For example, embedded systems often come with limited resources, which we can’t improve. Or when we build a global application that handles millions of requests per second, those additional performance requirements pile up to a significant impact. But these are relatively rare cases. Most of the time, readability is the determining factor.

7. TDD

We already mentioned that we need tests to be able to refactor. In TDD, we write the test before the code to fulfill it. It raises the question: how TDD and refactoring relate to each other?

The answer is that TDD and refactoring have a very intimate relationship. TDD has three steps:

  1. We write a failing test
  2. We make the test pass by writing production code
  3. Finally, we refactor the code and/or the test to make it more readable

We call this the red-green-refactor cycle. Red, because the tests are failing; therefore, they’re red. When we make them pass, they become green.

That’s one more reason why TDD is awesome. It doesn’t only guarantee that you’ll have high test coverage. It also considers that we should make our code more readable while we still know what it does. After all, there isn’t anything more unknown than the code we wrote last week.

8. Conclusion

Refactoring is a natural and essential part of a software’s evolution.

In this tutorial, we understood what it is and when and how we can do it. We talked about code smells and performance implications. We also saw that refactoring is a fundamental part of TDD.

With all this, it’s time to make our code more readable!