Refactoring: The Hard Parts
Tags: webdev, beginners, typescript, refactoring
Published: 2024-05-10 12:37:25

## Table of Contents
1. [Introduction](#introduction)
2. [The Process of Refactoring](#the-process-of-refactoring)
3. [Some Refactoring Techniques](#some-refactoring-techniques)
   - [Example: Simplifying User Data Processing](#example-simplifying-user-data-processing)
   - [Improved Version Using Guard Clauses](#improved-version-using-guard-clauses)
   - [Extracting Business Logic](#extracting-business-logic)
   - [Adapting to Future Business Changes](#adapting-to-future-business-changes)
4. [The Hard Parts](#the-hard-parts)
   - [Problem with Refactoring Directly in the Ticket](#problem-with-refactoring-directly-in-the-ticket)
   - [Problem with Creating a Separate Ticket](#problem-with-creating-a-separate-ticket)
5. [The Impact of Refactoring Time on Projects](#the-impact-of-refactoring-time-on-projects)
   - [You Don't Do Any Refactorings](#you-dont-do-any-refactorings)
   - [You Wait Until the Code Gets So Messy That You Have to Undertake a Huge Refactoring](#you-wait-until-the-code-gets-so-messy-that-you-have-to-undertake-a-huge-refactoring)
   - [You Do Small and Frequent Refactorings Over Time](#you-do-small-and-frequent-refactorings-over-time)
6. [My Practical Guideline](#my-practical-guideline)


## Introduction

Many concepts in software development help us write good code. Writing good code solely for its own sake is not practical. Instead, we write good code to enable businesses to add new functionalities more quickly. Ideally, this new functionality should generate more revenue than before. Although good code that does not generate revenue still holds technical merit, it fails to contribute financially. Thus, the primary goal of software is to generate revenue. Refactoring is a key technique that helps businesses introduce new functionalities effectively.

## The Process of Refactoring

Before delving into the more challenging aspects, we need to understand what refactoring involves. The refactoring process is relatively straightforward. The first critical step involves refactoring only non-trivial code for which we have tests. Personally, the order in which you write your functionality or tests doesn't matter to me. However, you ultimately need tests for refactoring to be effective.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0n0wdh4z50t50a0nmz8o.png)

So, the process can follow either of these sequences:

1. Add functionality -> Write tests -> Refactor
2. Write tests -> Add functionality -> Refactor

When mentioning refactoring, you must also mention tests, as the worst bugs often arise from refactoring processes.

It's important to recognise that refactoring merely changes the structure of your code; it does not introduce new features. Over time, a better structure will likely enable you to add functionalities more quickly and maintain and repair bugs more efficiently.

## Some Refactoring Techniques

On the following I want you to present one easy refactoring technique so that you can understand the concept better. 

### Example: Simplifying User Data Processing
Consider a function designed to process user data based on their activity status and age. Initially, the function might look like this:

```ts
interface User {
    age: number;
    isActive: boolean;
    name: string;
}

function processUserData(user: User) {
    if (user.isActive) {
        if (user.age >= 18) {
            // Process user data
            console.log(`Processing data for ${user.name}`);
        } else {
            console.log("User must be at least 18 years old.");
        }
    } else {
        console.log("User account is not active.");
    }
}
```

This function, while straightforward, is nested and can become difficult to manage as more conditions are added. Let’s refactor it using guard clauses to improve its structure.

## Improved Version Using Guard Clauses
By introducing guard clauses, we can reduce the nesting of conditions, making the code easier to read and maintain:

```ts
function processUserData(user: User) {
    if (!user.isActive) {
        console.log("User account is not active.");
        return; // Exit early if the user is not active
    }

    if (user.age < 18) {
        console.log("User must be at least 18 years old.");
        return; // Exit early if the user is not old enough
    }

    // Main logic if all conditions are met
    console.log(`Processing data for ${user.name}`);
}
```

### Extracting Business Logic

For further improvement, we can extract the condition checks into separate functions. This not only clarifies the main function but also makes the individual conditions reusable and easier to modify:

```ts
function isUserActive(user: User): boolean {
    return user.isActive;
}

function isUserAdult(user: User): boolean {
    return user.age >= 18;
}

function processUserDataRefactored(user: User) {
    if (!isUserActive(user)) {
        console.log("User account is not active.");
        return;
    }

    if (!isUserAdult(user)) {
        console.log("User must be at least 18 years old.");
        return;
    }

    console.log(`Processing data for ${user.name}`);
}
```

### Adapting to Future Business Changes

Suppose future business requirements dictate adding new criteria for determining if a user is active, such as checking recent login activity. With our refactored structure, you can modify the isUserActive function without impacting the overall logic of data processing:

```ts
const THIRTY_DAYS_IN_MILLISECONDS = 30 * 24 * 60 * 60 * 1000; 

function isUserActive(user: User, lastLoginDate: Date): boolean {
    const lastLoginThreshold = new Date(
        Date.now() - THIRTY_DAYS_IN_MILLISECONDS
    );
    return user.isActive && lastLoginDate > lastLoginThreshold;
}

function isUserAdult(user: User): boolean {
    return user.age >= 18;
}

function processUserDataRefactored(user: User, 
lastLoginDate: Date) {
    if (!isUserActive(user, lastLoginDate)) {
        console.log("User account is not active.");
        return;
    }

    if (!isUserAdult(user)) {
        console.log("User must be at least 18 years old.");
        return;
    }

    console.log(`Processing data for ${user.name}`);
}
```

These refinements underscore how small changes can significantly enhance code readability and adaptability. For those eager to dive deeper into refactoring, Martin Fowler's book on the topic, Refactoring: Improving the Design of Existing Code, is an excellent resource. While numerous refactoring techniques exist, the essence often lies in simplifying and isolating code components (functions classes etc), a practice beneficial in almost every software development scenario.

## The Hard Parts

Now, let's assume you understand how to refactor and how to write good tests that avoid testing implementation details while improving your testing approach. We can now address the Hard Parts. These involve deciding when to refactor, as in the real world, we software developers face time constraints. Often, we work on SCRUM projects where stakeholders and business expectations require us to complete a ticket within a set timeframe. Therefore, we cannot refactor continuously. We must choose the appropriate time to refactor. Essentially, we have two options:

1. Refactor directly in the ticket
2. Create a separate ticket to do the refactoring

### Problem with Refactoring Directly in the Ticket

In theory, the Boy Scout Rule is excellent:

> "Always leave your code cleaner than you found it."

However, in practice, it's challenging, especially with large, difficult-to-maintain codebases. As a developer, you rarely receive praise for consistently good refactorings—perhaps a nod on a peer's pull request at best. Worst case, you might be questioned why it took you longer. Therefore, fostering a culture where each developer is encouraged to exert extra effort and improve structure is essential. Even in such a culture, not all files are worth refactoring. For example, consider a huge, complex file that hasn't been modified in two years; does it really make sense to refactor it? Thus, determining the worth of a refactoring effort is crucial.

### Problem with Creating a Separate Ticket

Suppose you're working on a ticket and realize the code needs refactoring. Creating a separate ticket for this task can be time-consuming, especially in a Scrum environment where it will be estimated, discussed further, and perhaps 10% of the total time could be wasted just on process overhead, rather than on the actual refactoring. Additionally, this ticket might end up in the backlog, with no clear indication of when it will be prioritized. So, there are also significant issues with this approach.

## The Impact of Refactoring Time on Projects

One important aspect of refactoring is also time. There are essentially three ways refactorings could occur over time:

1. You don't do any refactorings.
2. You wait until the code gets so messy that you have to undertake a huge refactoring.
3. You do small and frequent refactorings over time.

### You Don't Do Any Refactorings

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gnyexbewlf04xr07actq.png)

In projects where developers continuously add features without conducting any refactorings, these projects often end up requiring a complete rewrite from scratch after a few years, or you simply have to live with the accumulated issues. Of course, the impact highly depends on the nature of the project, but generally, for projects with multiple developers involved, each new feature will take increasingly more time to implement.

### You Wait Until the Code Gets So Messy That You Have to Undertake a Huge Refactoring

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xi7e8flnrymbrymmg19v.png)

In some projects, after several years, developers might find it difficult or undesirable to add new features. This situation can lead to the necessity of a huge refactoring effort. The goal of such a refactoring is to make it easier to implement new features more quickly in the future.

### You Do Small and Frequent Refactorings Over Time

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ufmhg8u6fihtonkrrlfe.png)

In this approach, refactorings are just part of the normal development process. By integrating small refactorings regularly into your tasks, you hopefully rarely ever need to undertake a huge refactoring. This method helps maintain the code's manageability and scalability over time.

## My Practical Guideline

So, I hope you now understand the challenging aspects of refactoring—it's not just about **how** to do it, but also **where** and **when**. Personally, I always try to integrate small refactorings wherever I can, aiming for those low-hanging fruits. If I find myself needing to write a ticket for a huge refactoring that doesn't directly add any business value, it feels like a loss to me. Of course, even with this approach, from time to time, you will have to undertake a larger refactoring. However, it won't be as extensive if refactoring is routinely part of your normal tasks.