Table of Contents
- Introduction
- The Process of Refactoring
- Some Refactoring Techniques
- The Hard Parts
- The Impact of Refactoring Time on Projects
- 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.
So, the process can follow either of these sequences:
- Add functionality -> Write tests -> Refactor
- 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:
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:
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:
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:
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:
- Refactor directly in the ticket
- 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:
- You don't do any refactorings.
- 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 Don't Do Any Refactorings
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
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
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.
Top comments (2)
nice! In your first example - nested function vs guard clause - it looks like maybe you pasted the same code twice though
ah True thank you my mistake did update the code