Chapter 1. Refactoring

Someone once asked me what it was that I liked so much about refactoring. What kept me coming back to these types of projects at work so often? I told her that there was something addicting about it. Maybe it’s the simple act of tidying, like neatly cataloging and ordering your spices; or maybe it’s the joy of decluttering and finally deprecating something, like bringing a bag of forgotten clothes to Goodwill; or maybe yet it’s the little voice in my head reminding me that these tiny, incremental changes will amount to a significant improvement in my colleagues’ daily lives. I think it’s the combination of it all.

There’s something in the act of refactoring that can appeal to us all, whether we’re building new product features or working on scaling an infrastructure. We all must strike a balance in our work between writing more or writing less code. We must strive to understand the downstream effects of our changes, whether intentional or not. Code is a living, breathing thing. When I think about the code that I’ve written living on for another five, ten years, I can’t help but wince a little bit. I certainly hope that by that time, someone will have come along and either removed it entirely or replaced it with something cleaner and, most importantly, more suited to the needs of the application at that time. This is what refactoring is all about.

In this chapter, we’ll start by defining a few concepts. We’ll propose a basic definition for refactoring in the general case and build on top of it to develop a separate definition for refactoring at scale. To frame some of the motivations of this book, we’ll discuss why we should care about refactoring and what advantages we can bring to our teams if we’ve honed this skill. Next, we’ll dive into some of the benefits to expect from refactoring and some of the risks we should keep in mind when considering whether to do it. With our knowledge of the trade-offs, we’ll consider some scenarios when the time is right and when the time is wrong. Finally, we’ll walk through a short example to bring these concepts to life.

What Is Refactoring?

Very simply put, refactoring is the process by which we restructure existing code (the factoring) without changing its external behavior. Now if you think that this definition is incredibly generic, don’t worry; it purposefully is! Refactoring can take many equally effective forms, depending on the code it’s applied to. To illustrate this, we’ll define a “system” as any defined set of code that produces a set of outputs from a set of inputs.

Say we have a concrete implementation of such a system called S, pictured in Figure 1-1. The system was built under a tight deadline, encouraging the authors to cut some corners. Over time, it’s become a large pile of tangled code. Thankfully, consumers of the system aren’t exposed to the internal mess of the system directly; they interact with S, using a defined interface and rely on it to provide consistent results.

A simple system with inputs and outputs
Figure 1-1. A simple system with inputs and outputs

A few brave developers cleaned up the internals of the system, which we’ll now call S’, picture in Figure 1-2. While it might be a tidier system, to the consumers of S’, absolutely nothing has changed.

A simple refactored system with inputs and outputs
Figure 1-2. A simple refactored system with inputs and outputs

System S could be anything; it could be a single if statement, a ten-line function, a popular open source library, a multimillion-line application, or anything in between. (Inputs and outputs could be equally diverse.) The system could operate on database entries, collections of files, or data streams. Outputs aren’t limited to returned values, but could also include a number of side effects such as printing to the console or issuing a network request. You can see how a RESTful service responsible for operating on user entities might map to our definition of a system in Figure 1-3.

A simple application as a system
Figure 1-3. A simple application as a system

As we continue to build on our definition of refactoring and begin exploring different aspects of the process, the best way to ensure we’re all on the same page is to connect each idea to a single, concrete example.

Using real-world programming examples is difficult for a few reasons. Given the breadth of experiences in our industry, choosing just one example over another immediately gives one group of readers a leg up. On the flip side, those deeply familiar with the example might be frustrated when some concepts are simplified for brevity or when certain nuances are ignored to apply a concept more cleanly. In hopes of establishing a level playing field, whenever we seek to illustrate a generic problem at a high level, we’ll use as our example a business familiar to (hopefully) most of us: a dry cleaning establishment.

Simon’s Dry Cleaners is a local dry cleaning business with a single location on a busy street in Springfield. It’s open Monday through Saturday during regular business hours. Customers drop off both regular laundry and dry-clean-only items. Depending on the quantity, urgency, and difficulty of each item, the items are cleaned and returned to the customers any time between two and six business days later.

How does this map to our definition of a system? The dry cleaning operation housed within the business is the system itself. It processes customers’ dirty clothing as inputs and returns them cleaned to their owners as outputs. All of the intricacies of the dry cleaning operation are hidden from the consumer; all we need to do is drop off our clothes and hope the cleaners are able to do their job. The system itself is quite complex; depending on the type of input (leather jacket, pile of socks, silk skirt), it may respond by performing one or more operations to ensure the proper output (a clean garment). There is ample opportunity for something to go wrong between drop-off and pickup: a belt might get lost, a stain overlooked, a shirt accidentally returned to the wrong customer. However, if the employees proactively communicate with one another, the machines are in good condition, and the receipts are kept in order, the system will continue to operate smoothly and it’ll be easy to fulfill orders.

Let’s say Simon’s still ran its operations using paper carbon-copy receipts. All customers coming in to drop off their clothes would write their name and phone number on the provided slip, and the clerk would take note of their order. If customers misplaced their receipts, Simon’s could easily locate the copy by leafing through their recent orders alphabetized by last name. Unfortunately, when customers are late to pick up their dry cleaning and they’ve misplaced their receipts, the clerk has to fetch archived slips from boxes in the back office. Although almost all orders are successfully retrieved, it takes much more time for the customer to pick up their apparel and be on their way again. Paper receipts are also inconvenient when the owners calculate their earnings at the end of each month; they have to match up all transactions (both credit card and cash) manually with completed orders. Eager to modernize and refactor their process, the team decided to upgrade their systems to use a point-of-sale system and erase the pain points of paper. Ta da, refactoring complete! Customers continue to drop off their dry cleaning and retrieve it a few days later with minimal perceived change, but now everything behind the front desk runs much more smoothly.

What Is Refactoring at Scale?

In late 2013, amidst a tumultuous launch, all major American news outlets declared Healthcare.gov a complete fiasco; the website was plagued with security concerns, hours-long outages, and a slew of serious bugs. Before launch, not only had the cost ballooned to nearly two billion dollars, the codebase had blown up to over five million lines of code. While much of the failure of Healthcare.gov was due to failed development practices caught up in bureaucratic federal government policies, when the Obama administration later announced that it was planning to invest heavily in improving the service, the undeniable difficulty involved with rearchitecting and refactoring overgrown software systems became mainstream news. In the subsequent months, the teams tasked with rewriting Healthcare.gov dove headfirst into a near-complete overhaul of the codebase, a refactor at scale.

A refactor at scale is one that affects a substantial surface area of your systems. It typically (but not exclusively) involves a large codebase (a million or more lines of code) powering applications with many users. As long as legacy systems exist, there will be a need for these kinds of refactors, ones where developers need to think critically about code structure at breadth and how it can be measurably improved effectively. What makes refactoring multimillion-line codebases different from refactoring smaller, more well-defined applications? While it might be easy for us to see concrete, iterative ways to improve small, well-defined systems (think individual functions or classes), it becomes nearly impossible to determine the effect a change might have when applied uniformly across a sprawling, complex system. Many tools exist to identify code smells or automatically detect improvements within subsections of code, but we are largely unable to automate human reasoning about how to restructure large applications in codebases that are growing at an increasingly rapid pace, particularly at high-growth companies.

Some may argue that you can make a measured improvement to this kind of system by continuously applying small, additive transformations. This method might begin to tilt the scales in a positive direction, but progress is likely to drop off significantly when most of the low-hanging fruit is gone and it becomes trickier to introduce these changes carefully (and gradually).

Refactoring at scale is about identifying a systemic problem in your codebase, conceiving of a better solution, and executing on that solution in a strategic, disciplined way. To identify systemic problems and their corresponding solutions, you’ll need a solid understanding of one or more broad sections of an application. You’ll also need high stamina to propagate the solution properly to the entire affected area.

Refactoring at scale also goes hand in hand with refactoring live systems. Many of us work on applications with frequent deployment cycles. At Slack, we ship new code to our users about a dozen times per day. We need to be mindful of how our refactoring efforts fit into these cycles, to minimize risk and disruption to our users. Understanding how to deploy strategically at various points during a refactoring effort can oftentimes make the difference between a quiet rollout and a complete service outage.

What might Simon’s Dry Cleaners look like when considering scale? Say deploying a point-of-sale system dramatically optimized the business—so much so, in fact, that it managed to open five new locations in neighboring towns in just two years! Now that it’s operating multiple locations, growing the scale of their business, they have a different set of problems. To keep costs low, only two of their six locations have dry cleaning equipment on-site. When customers drop off dry cleaning at one of the four locations that do not have dry cleaning equipment on-site, the apparel must be sent to the closest facility via the company van. The van stops at all four storefronts to pick up clothes, dropping them off in large bins on the loading docks of the two dry cleaning locations. Simon’s employees work hard to sort through the heaps of clothes, clean them, and return them to the correct storefront. Most days, however, it’s a harrowing process. Both dry cleaning locations process apparel from both their own location and the four smaller ones. It’s not uncommon for clothes to get separated or tangled when dropped into the processing bins by the van drivers. More urgent orders often get lost in the heap and cleaners have to dig through the entire shipment to identify them first.

How can Simon’s improve its operations most efficiently? Should it dedicate a specific dry cleaning center for each location so that each facility is handling orders from a maximum of three storefronts? If so, should it consider rerouting the vans in a specific way? What if it did both? Would it be cost-efficient to open yet another dry cleaning facility if it enables the business to decrease turn-around time? How should it set up its loading docks so that fewer clothes get tangled? Could the drivers be taught to hang and categorize orders properly by urgency before driving off to make another round? Should the company limit pickups to right after lunchtime and shortly after closing to give the dry cleaning locations more time to organize the drop-offs? There are quite a few options to consider, many of which could be combined and executed on numerous orders or simultaneously. Imagine being faced with all of these possibilities and having to decide which lever to pull first. It’s positively paralyzing! Turns out, refactoring large applications feels the same way.

Why Should You Care About Refactoring?

Refactoring might sound compelling in theory, but how do you know that reading the rest of this book won’t be a waste of time? I certainly hope that all readers can walk away from this book with a few new tools in their tool belt, but if there’s a single reason I can provide to keep you reading it’s this:

Confidence in your ability to refactor allows you to lean toward action and start building a system sooner, well before you’ve developed a strong understanding of all the moving pieces, pitfalls, and edge cases. If you know you’ll be able to identify opportunities to improve components effectively throughout the development process, and will continue to be able to do so as the system grows more complex, you won’t need to spend as much time architecting a program upfront. Once you’ve honed the skills required to manipulate code effortlessly, you’ll spend less time worrying about boxing yourself in with any single design decision. While programming, you’ll find yourself opting to write something simple that works given the current circumstances rather than stepping back and planning your next half-dozen moves. You’ll recognize that there is always a (sometimes tricky) path to a better solution.

Programming isn’t a game of chess. When given a board configuration and assuming optimal opponents, the best competitive players deftly play out dozens of complete matches within minutes. Unfortunately, in our line of work, we aren’t provided a fully enumerated set of possible moves and there is no predetermined end state. I don’t mean to imply that there is no value in sitting down and brainstorming a robust solution to a problem, given a reasonable set of requirements; however, I do want to caution you against spending any significant time ironing out the final 10 percent to 20 percent. If you’ve honed your ability to refactor, you’ll be able to evolve your solution to handle the final specifications just fine.

Benefits of Refactoring

Refactoring can have some tangible benefits beyond the ability to start confidently problem-solving sooner. Though it might not be the correct tool for every problem, it can certainly have a lasting, positive impact on your application, engineering team, and broader organization. We discuss two major benefits: increased developer productivity and greater ease identifying bugs. While some might contend that there are many more benefits to refactoring than those discussed here, I argue that they all boil down to the two themes presented here.

Developer Productivity

One of the primary goals of refactoring is yielding code that is easier to understand. Simplifying a dense solution as you reason through it not only helps you gain a better grasp of what the code is doing, it also helps everyone who comes after you do the same. Code you can easily comprehend elevates absolutely everyone on your team, no matter their tenure or experience level.

If you are a tenured engineer on the team, you tend to be very familiar with some parts of the codebase but, as the codebase grows, more and more parts are unfamiliar to you, and your code is increasingly likely to develop dependencies on those parts. Imagine that you’re implementing a new feature and in weaving your solution through the system, you venture from code you know rather well to unfamiliar territory. If the area unknown to you is well maintained and regularly refactored to take into account evolving product requirements and bug fixes, you’ll be able to narrow down the ideal location for your change and intuit an effortless solution much more quickly. If the code has instead deteriorated over time by accruing patchy bug fixes and ballooning in length, you’ll spend exponentially more time wading through each line, trying first to understand what the code is doing and how it’s doing it before you’re able to spend any time reasoning through an acceptable solution. (It’s not uncommon to drag someone else into the tortured-code rabbit-hole, whether it’s another engineer working alongside you or one who’s intimately familiar with the code to answer your questions.)

Let’s flip the scenario. What if a colleague on another team who isn’t familiar with your team’s code had to take a stab at reading through it. Would they have an easy time understanding how it works? Are you more likely to expect questions and confused looks, or a request for code review?

What if you were a new engineer on the team. Perhaps this was you just recently or maybe you recently onboarded someone to your team, whose experiences you can pull from. They have absolutely no mental model of the codebase. Their ability to gain confidence with any area of the code is directly proportional to the code’s legibility. Not only will they be able to organically build up an accurate mental representation of the relationships between different units in your codebase, they’ll be able to reason out what the code is doing without needing to tag teammates for questions. (It’s worth noting that knowing when and how to ask questions of your colleagues is an incredibly important skill to hone. Learning to evaluate how much time is appropriate for you to build your own understanding before seeking help is difficult but critical to growing as a developer. Asking questions isn’t a bad thing, but if you’re the tenured engineer on the team and you’re feeling bombarded with them, maybe it’s time to write some documentation and refactor some code.)

We’re all prone to copying established patterns when developing something new. If the solutions we reference are clear and concise, we’re more likely to propagate clear and concise code. The converse is also true: if the only solutions we have as reference are cluttered, we’ll propagate cluttered code. Ensuring that the best patterns are the most prevalent ones is particularly crucial in establishing a positive feedback loop with developers who are just starting out. If the code that they interact with on a regular basis is easy to understand, they’ll emulate a similar focus in their own solutions.

Identifying Bugs

Tracking down and solving bugs is a necessary (and fun!) part of our jobs. Refactoring can be an effective tool in accomplishing both of these tasks! By breaking up complex statements into smaller, bite-sized pieces, and extracting logic into new functions, you can both build up a better understanding of what the code is doing and, hopefully, isolate the bug. Refactoring as you are actively writing code can also make it easier to spot bugs early in the development process, allowing you to avoid them altogether.

Consider the scenario in which your team deployed some new code to production a few hours ago. A few of the changes were embedded in a handful of files that everyone fears modifying: the code is impossible to read and contains a minefield of bugs waiting to happen. Unfortunately, your tests didn’t cover one of many edge cases and someone from customer service reaches out about a pesky bug users are starting to run into. You and your team immediately start digging in and quickly realize that the bug is, as expected, in the scariest part of the code. Thankfully, your teammate’s able to reproduce the problem consistently and, together, you write a test to assert the correct behavior. Now you have to narrow down the bug. You take methodical steps to break down the hairy code: you convert lengthy one-liners into succinct, multiline statements and migrate the contents of a few conditional code blocks into individual functions. Eventually, you locate the bug. Now that the code’s been simplified, you’re able to fix it swiftly, run the test to verify that it works, and ship a fix to your customers. Victory!

To the customer, sometimes bugs are only a minor nuisance, but other times, bugs can prevent the customer from using your application altogether. While more disruptive bugs generally require urgent remediation, it’s imperative that your team be able to solve bugs of all severity levels quickly to keep users happy. Working in a well-maintained codebase can dramatically decrease the time developers need to hone in on and fix a bug, delighting you when it’s shipped to production in record time.

Risks of Refactoring

While the benefits of refactoring might be compelling, there are some serious risks and pitfalls to consider before setting out on a journey to improve every inch (or centimeter) of your codebase. I may be starting to sound like a broken record, but I will reiterate it nonetheless: refactoring requires us to be able to ensure that behavior remains identical at every iteration. We can increase our confidence that nothing has changed by writing a suite of tests (unit, integration, end to end), and we should not seriously consider moving forward with any refactoring effort until we’ve established sufficient test coverage. However, even with thorough testing, there is always a small chance that something slips through the cracks. We also must keep in mind our ultimate goal: bettering the code in a way that is clear to both you and future developers interacting with the code.

Serious Regressions

Refactoring untested code is very dangerous and highly discouraged. Development teams equipped with the most thorough, sophisticated testing suites still ship bugs to production. Why? With every change, large or small, we disrupt the equilibrium of the system in a measurable way. We strive to cause as little disruption as possible, but whenever we alter our systems, there is a risk that it might lead to unanticipated regression. As we refactor the exceptionally frightening, puzzling corners of our codebase, introducing a serious regression is of particular concern. These areas of the codebase are frequently in their current state because they’ve had plenty of time to deteriorate. At fast-growing companies, they are also frequently both integral to how your application works and the least tested. Attempting to detangle these files or functions can feel like trying to walk across a minefield unscathed—it’s possible, but very dangerous.

Unearthing Dormant Bugs

Just as refactoring can help you identify bugs, it can unintentionally unearth dormant bugs. Here, I classify dormant bugs as regressions that are most commonly exposed by restructuring code. We’ll revisit Simon’s Dry Cleaners to illustrate. The business has started ordering cleaning products in bigger batches at the same delivery cadence to unlock a better deal from the supplier. Unfortunately, there’s not much room to store the products in the back of the main storefront, so Simon’s decides to start stacking boxes closer to the loading dock door. After a few weeks of rain, the team notices that some of the boxes closest to the door are wet and falling apart. The owner notices that the back door is poorly sealed and allows water to seep through on wet days. Simon’s had never encountered a problem with storing supplies close to the loading dock door because they’d simply never done it before; exercising a new storage pattern exposed a critical flaw in their infrastructure, which they might have never discovered otherwise.

Scope Creep

Refactoring can be a little bit like eating brownies: the first few bites are delicious, making it easy to get carried away and accidentally eat an entire dozen. When you’ve taken your last bite, a bit of regret and perhaps a twinge of nausea kick in. Experiencing immediate, highly significant improvements when you’re making focused, localized changes is incredibly rewarding! It’s easy to get carried away and allow the surface area of your changes to exceed reasonable bounds. What do I mean by reasonable bounds? Depending on the codebase, this can refer to a single functional area or a small, interdependent set of libraries. Ideally, the refactored code is limited to a set of changes another developer can comfortably review within a single changeset.

When mapping out a larger refactoring effort, especially one that might take several months or more, it’s absolutely imperative to keep a tight scope. We all run into unexpected quirks when refactoring small surface areas (a few lines of code, single functions); while we can sustainably chain a few enhancements to handle these new quirks effectively, this approach becomes dangerous when tackling a significant surface area. The larger the surface area of the planned refactor, the more problems you’ll encounter that you likely haven’t anticipated. That doesn’t make you a bad programmer, it simply makes you human. By keeping to a well-defined plan, you decrease the chances of causing a serious regression or running into dormant bugs, and promote productivity. Sustained, methodical refactoring efforts are already difficult; having a moving goalpost simply makes them unachievable.

Unnecessary Complexity

Be wary of over designing at the start and be open to modifying your initial plan. The primary goal should be to produce human-friendly code, even at the cost of your original design. If the laser focus is on the solution rather than the process, there’s a greater chance your application will end up more more contrived and complicated than it was in the first place. Refactoring at all levels should be iterative. By taking small, deliberate steps in one direction and maintaining existing behavior at each iteration, you’re better able to maintain focus on your ultimate goal. This is much easier to do when tackling only enough code as fits on your screen rather than three dozen libraries at a time. When we plan a new project, most of us generally try our best to develop a detailed specification document and execution plan. Even with a large refactoring effort, it’s important to have a good sense of what the resulting code should look like upon completion.

When to Refactor

It would be easy simply to say “when the benefits outweigh the risks,” but that wouldn’t be a helpful answer. Yes, in practice, refactoring is a worthwhile effort when the benefits outweigh the risks, but how do we properly assign weight to each piece of the puzzle? How do we know when we’ve reached the tipping point and should consider a refactor?

In my experience, the tipping point is more of a tipping range, and it is different for everyone and every application. Determining your upper and lower bounds for this range is what makes refactoring a bit more of a subjective science: there is no formula we can use to give us a decisive “yes” or “no” answer. Fortunately, we can rely on some empirical evidence from others’ experiences to guide us in making our own decisions.

Small Scope

When looking to refactor a small, straightforward section of well-tested code, there should be very little holding you back. Unless you’re uncertain that your refactored solution is an objective improvement to its predecessor, or you’re fearful the change affects too large of a surface area, it’s likely a worthwhile endeavor. Carefully craft a few commits and get your changes rolling! We’ll see an example that clearly falls into this category later in this chapter.

Code Complexity Actively Hinders Development

There are times when we have to venture into parts of our codebase we fear. Every time we read over the code, our brows furrow, our hearts pound, our neurons start firing. Then comes the moment when we have to bite the bullet, dig in, and make the change we came to make. But developing under duress is a surefire way to inadvertently cause more problems. When you’re so hyper-focused on doing precisely the correct thing, holding the many dimensions of the problem in your head, you risk losing sight of your actual goal. How can you execute adequately on that goal when your mind is elsewhere?

If this particular section of the code hasn’t bitten us yet, we’ll often take our chances and make it. If it’s bitten us or a fellow teammate already (sometimes more than once), the risk involved in taking a scalpel to the code now to prevent future mistakes might outweigh the risk of letting it linger in its current state any longer. If you’re unsure which way the scales tilt, talk it over with your teammates and collect some data on the number of bugs caught in the past six months that you can trace back to this part of the codebase.

Shift in Product Requirements

Drastic shifts in product requirements can frequently map to drastic shifts in code. As hard as we might try to write abstract, extendable solutions for each piece of functionality in our application, we can’t predict the future; and while our code might be easy to adapt for small deviations, it is seldom perfectly adaptable to larger ones. These shifts give us the rare business-related opportunity to go back to the drawing board and reconsider our design.

You may be thinking that these sorts of shifts can’t possibly preserve behavior. Given the same inputs, now we must provide different outputs! How is this an opportune time for refactoring? If your code in its current state doesn’t lend itself well to the new requirements, you must come up with a solution that continues to support today’s functionality and will seamlessly support tomorrow’s. You can make a case for refactoring your code first, and then (and only then!) implement the new functionality atop it. This way, you continue to set a standard of high-quality code, cashing in all the benefits of refactoring, all the while supporting business objectives. Again, it’s a win, win, win!

Performance

Improving performance can be a difficult task; you must first build a deep understanding of the existing behavior and then be able to identify which levers you might be able to use to tilt the scales in a positive direction. Beginning with a clean slate (or building one as a first step) will best enable you to do that. Properly isolating the levers you’ve identified so that they are easier to manipulate without risk of downstream effects is also key.

Note

Not all developers believe that performance improvements are a valid reason to refactor; some assert that a system’s performance is innately part of its behavior and therefore altering it in some way alters the behavior. I disagree. If we continue to define refactoring by using our generic system to which we provide a set of inputs, and continue to produce an expected set of outputs, then improving the speed (or memory burden) required to generate these outputs is a valid form of refactoring.

Refactoring for this purpose is unique in one important way: it does not ensure more approachable code as an outcome. Sometimes we’ll be reading through a codebase and come across a lengthy comment block warning about the code below it. In my experience, most of these comment blocks caution the reader about one (or more) complications: strange application behavior, temporary workarounds, and a peculiar performance patch. Most performance improvements prefaced by these short stories are written cleverly and leverage a deep understanding of the code base as a means of minimizing the surface area affected. These “improvements” are more susceptible to degradation over a shorter period and as such are not good examples of the sustainability that refactoring is meant to foster. The worthwhile performance improvements, the ones worthy of falling under the refactoring umbrella, are profound and far-reaching; they are examples of effective refactoring deployed at scale. We’ll cover these changes in greater depth in Part II.

Using a New Technology

In the world of software development, we’re regularly adopting new technologies. Whether it’s to keep up with the newest trends in our industry, boost our ability to scale to more users, or mature our product in a new way, we’re perpetually evaluating new open-source libraries, protocols, programming languages, service providers, and more. Making the decision to use something new is not something we do lightly; this is partly due to the cost of integration within our existing codebases. If we opt to replace an existing solution with a new one, we have to craft a deprecation plan by identifying all affected callsites and migrating them (sometimes one at a time). If we opt to adopt a new technology moving forward, we have to identify high-leverage candidates for early adoption, with a plan to expand usage to all relevant use cases.

I won’t enumerate each of the ways using a new technology can affect your system (there are many), but it’s clear from these two scenarios that each requires a careful audit of your current system. Fortunately, an audit can reveal prime opportunities for refactoring! I want to take the time to acknowledge that this is a somewhat controversial opinion. Because of the risks involved with adopting a new technology alone, other developers may discourage you from making any other changes. However, I strongly believe that the worst way to introduce something new into your system is to stick it right in alongside a huge, tangled mess. To give it the best chance to fulfill its purpose, I think it’s best to take the time to clean up the areas it’ll come in contact with first.

We can easily apply this concept to Simon’s Dry Cleaners. Let’s say it just recently put in an order for some new state-of-the-art, eco-friendly dry cleaning machinery. In figuring out an installation plan, the owners realize that their existing floor plan has some serious inefficiencies. Employees have to walk all the way along the line of machines to pick up presorted garments from the racks nearly thirty feet away. If they reorient the machinery so that employees can walk just a few feet to reach the racks, they might shave a few minutes off of every cycle. They make the decision to install the new machines in the revised configuration. Simon’s may have decreased its impact on the environment and increased the productivity of their employees. Win, win!

When Not to Refactor

Refactoring can be an astonishingly useful tool to a developer. Many developers believe that time devoted to refactoring is always time well spent, but it isn’t so simple. There is a time and a place for refactoring, and the most mature developers understand the importance of knowing when to refactor and when not to refactor.

For Fun or Out of Boredom

Close your eyes for a minute and imagine yourself sitting in front of your computer. You’re looking at a particularly gnarly function. It’s too long; it tries to do too many things. Its name has long since ceased to describe its responsibility meaningfully. You’re itching to fix it. You’d love to split it up into well-defined, succinct units complete with better variables names. It’d be fun. But is it the most important thing you could be doing right now? Perhaps your teammate’s been waiting for your code review for a few days or you’ve been putting off writing some tests? If you’re digging into some crufty old code and shifting it around to keep yourself entertained, you might be doing yourself (and your teammates) a disservice.

Chances are, if you’re refactoring for fun, you’re not focusing on the impact that your change will have on the surrounding code, the overall system, and your coworkers. We have different motivations when we’re refactoring for fun: we’re more likely to use more far-fetched language features or try out a brand new pattern we’ve been wanting to give a whirl. There is a time and place for trying new things and stretching our programming muscles, but refactoring isn’t that time. Refactoring should be a deliberate process where the focus is strictly on providing the (ideally) smallest change for the biggest positive impact.

Because You Happened to Be Passing By

Picture this: you write some code, ship it to production, and start working on a new feature. You come back to your code a few months later to expand on the feature. Unfortunately, it looks nothing like what you originally wrote. A million questions are racing through your mind. What happened here?

You may have fallen prey to the drive-by refactorer. This is a coworker who is experienced enough to have developed some well-informed opinions about how to write code. They are someone whom other engineers consult with about design decisions. They also have an unfortunate tendency to rewrite others’ code as they encounter it. They think they’re doing everyone a favor by doing this.

You might be tempted to agree, but consider this: if this engineer modified code in an area of the codebase where they are not an active contributor, it’s likely they’ve decreased the productivity of those that are responsible for it. We are most productive when we are familiar with the code for which we are responsible. When we’re tasked with quickly resolving an issue, whether it is a serious incident in production or a small bug, we use our mental model of the code to narrow a set of files, classes, or functions where the problem might exist. If we open up our editor and find that nothing is where we left it, we’re disoriented and unable to fix the issue as quickly. This is incredibly costly to our employers in engineering hours, customer service hours, and potentially lost business.

Not telling the original author about the refactor is a disservice in two distinct ways. First, they have actively eroded the author’s trust. As much as we try to divorce ourselves from our code, we always leave a tiny piece of personal pride and ownership in the code that we’ve written. I’d much prefer if someone were honest with me about the shortcomings of my solution and shows me how to fix it rather than find out about the problems after they’ve already been addressed. This is particularly harmful when it comes to newer engineers. Imagine yourself just one year out of school; you come into work one day only to find that the code that you’d taken weeks to cobble together had been rewritten in a few hours by a much more senior engineer whom you’ve never talked to. It doesn’t feel great.

Second, they may not be aware of the initial circumstances surrounding the code at the time it was written. This is particularly troublesome when dealing with code that the drive-by refactorer is not actively maintaining. Why is this important? Programming is all about trade-offs; we can write a faster solution by using a more memory-intensive data structure or reduce our memory footprint by approximating rather than making precise calculations. Likewise, every line of “bad” code attempted to solve a problem. By blindly refactoring it, you may fall prey to a bug or weakness the original authors were carefully trying to avoid.

Don’t be a drive-by refactorer, be a well-intentioned refactorer. Rarely refactor code that you are not actively maintaining, and when you do, make sure you’re doing it with the input of those responsible for it.

To Making Code More Extendable

Many refactoring gurus advocate for refactoring as a means to render code more readily extendable. While this can be a clear outcome of a good refactor, rewriting code for the sake of future malleability is likely unwise. Time spent refactoring without a clear understanding of the immediate, tangible wins might be a wasted effort; your changes might not pay off within a reasonably short period, nor, in the absolute worst case, within the lifetime of the code.

If you can make adequate changes to a block of code to advance your project, you probably shouldn’t be refactoring it. Most companies have new features to develop and bug fixes to ship. Generally speaking, these are almost always of higher priority. Unless you have a concrete set of goals, and a compelling argument that it will directly affect your company’s bottom line, your management chain will be unconvinced. But don’t dismay! We’ll help you build a business case for refactoring in the coming chapters.

When You Don’t Have Time

The only thing worse than code in dire need of refactoring is code half-refactored. Code in limbo is confusing to developers interacting with it. When there is no clear point in time when the code will be fully refactored, it takes on semi-permanent disorder. It’s often difficult for the reader to discern the direction or implementation to follow when reading code mid-refactor, especially if the refactorer left no comments in their wake. You might even make an incorrect assumption about which code will be adopted long-term and implement a necessary change in a block that’s headed for deprecation. These kinds of mistakes pile up quickly, leading to faster, more serious erosion of the code you hoped to improve in the first place.

When setting out to refactor something, make sure you have enough time to see your plans through to completion. If not, try to scope down your changes so that you can still make some improvements but comfortably reach the finish line. No temporary benefits reaped from an incomplete refactor outweigh the confusion and frustration of future developers interacting with it.

Our First Refactoring Example

Now that we’ve built a solid foundation with which to begin understanding the goals of refactoring and how, under the right circumstances, it can enable us to be better programmers, let’s bring it all to life with a small example. This example is much smaller in scope than the kinds of refactoring efforts we’ll be discussing in this book, but it helps illustrate some of the concepts on a smaller scale so that we can get familiar with them early.

Let’s pretend we’re working at a university where we develop and support a rudimentary program that teaching assistants (TAs) use to submit assignment grades. The TAs use the program to verify that assignment grades fall within a certain range specified by the professor. This range is configurable because professors structure assignments differently, so not all problem sets are graded on a 0 to 100 point scale. Take, for example, a problem set with 10 questions. Each question is worth a maximum of 6 points. If you answer all questions correctly, your final grade is 60 out of 60. If you don’t submit the assignment at all, you’ll get 0 points.

Professors use the same tool to ensure that the average score for a given assignment falls within an expected range. Given our previous example, say the professor would like the average for the problem set to be within 42 and 48 points (for a percentage score between 70% and 80%). They can provide this expected range to the program, which then processes the final grades and determines whether the average falls within those bounds.

The function responsible for this logic is called checkValid and is shown in Example 1-1.

Example 1-1. A small, confusing sample of code
function checkValid(
  minimum,
  maximum,
  values,
  useAverage = false)
{
  let result = false;
  let min = Math.min(...values);
  let max = Math.max(...values);
  if (useAverage) {
    min = max = values.reduce((acc, curr) => acc + curr, 0)/values.length;
  }

  if (minimum < 0 || maximum > 100) {
    result = false;
  } else if (!(minimum <= min) || !(maximum >= max)) {
    result = false;
  } else if (maximum >= max && minimum <= min) {
    result = true;
  }
  return result;
}

Right off the bat, we can spot some problems. First, the function name doesn’t fully capture its responsibilities. We’re not entirely certain what to expect from a function with a generic name like checkValid (especially if there isn’t any documentation atop the function declaration). Second, it’s unclear what the inlined values (0, 100) represent. Given what we know about the function’s expected behavior, we can deduce that these numbers represent the absolute minimum- and maximum-allowed point values for any assignment. Within the context, the minimum value of 0 makes sense, but why assert an upper bound of 100? Third, the logic is difficult to follow; not only are there quite a few conditions to reason through, the inlined logic can be complex, making it difficult for us to reason through each case quickly. At a quick glance, it’s nearly impossible to know whether the function contains a bug. We could spend considerable time enumerating the many issues contained within these few short lines of code, but to keep things simple, we’ll stop here.

How could so few lines of code be so tough to understand? Code in active development is regularly modified to handle small, low-impact changes (bug fixes, new features, performance tweaks, etc.). Unfortunately, these modifications pile up, oftentimes resulting in lengthier, more convoluted code. From the code structure, we can identify two changes that probably occurred after the function was initially written:

  • The ability to perform range validation on the average of the provided set of values rather than the sum of those values. I can infer that this functionality was introduced later for two reasons; useAverage is an optional Boolean argument with a default value of false, implying that there are existing callsites that do not expect a fourth argument. Boolean arguments are a code smell; we’ll address that shortly. Further, the code overwrites both min and max to reflect the single, new average value for convenience. This indicates that the author was looking for the easiest way to handle this requirement while modifying the least amount of code.

  • Ensuring that no provided range fell below 0 nor exceeded 100. It seems strange to disallow professors from creating assignments worth more than 100 points, but we can assume that this was intended behavior for now. Although it isn’t a conclusive clue, we can guess this behavior was introduced as an afterthought because of the placement of the conditional to verify the range’s absolute limits. Why would we not immediately verify that the provided minimum and maximum bounds are within the acceptable range? The author of the change likely quickly identified the series of conditionals and thought the easiest place to add a new condition would be at the very end. We could confirm our hypothesis by looking through the version history and hopefully finding the original commit with a helpful commit message.

Simplifying Conditionals

First, let’s simplify some of the if statement logic. We can easily do that by returning a result from the function early rather than evaluating every branch and returning a final value. We’ll also return early in the case that the provided minimum and maximum values fall outside the 0, 100 range, as shown in Example 1-2.

Example 1-2. A small sample with early returns
function checkValid(
  minimum,
  maximum,
  values,
  useAverage = false
) {

  if (minimum < 0 || maximum > 100) return false; 1

  let min = Math.min(...values);
  let max = Math.max(...values);

  if (useAverage) {
    min = max = values.reduce((acc, curr) => acc + curr, 0)/values.length;
  }

  if (!(minimum <= min) || !(maximum >= max)) return false; 2
  if (maximum >= max && minimum <= min) return true; 2

  return false;
}
1

Return early if the minimum or maximum is out of range.

2

Simplify the logic by returning early when we can.

Now we’re getting somewhere! Let’s see whether we can further simplify the logic by reasoning through all of the cases for which the function would return false: there’s the case where the calculated minimum is smaller than the provided minimum and the case where the calculated maximum is greater than the provided maximum. We can replace the current conditions by failing early and only returning a true result after verifying each of these simple failure cases instead. Example 1-3 illustrates each of these changes.

Example 1-3. A small sample with simplified logic
function checkValid(
  minimum,
  maximum,
  values,
  useAverage = false
) {

  if (minimum < 0 || maximum > 100) return false;

  let min = Math.min(...values);
  let max = Math.max(...values);

  if (useAverage) {
    min = max = values.reduce((acc, curr) => acc + curr, 0)/values.length;
  }

  if (min < minimum) return false; 1
  if (max > maximum) return false; 1
  return true; 2
}
1

Simplify logic by reasoning through the cases and only having one condition per if statement.

2

Fail early and return true only if we’re certain that the values are valid.

Extracting Magic Numbers

Our next step will be to extract the inlined numbers (or magic numbers) into variables with informative names. We’ll also rename values to grades for clarity. (Alternatively, we could define these as constants within the same scope as the function declaration, but we’ll keep things simple for now.) Example 1-4 demonstrates these clarifications.

Example 1-4. A small sample with clearer variables
function checkValid(
  minimumBound, 1
  maximumBound, 1
  grades, 1
  useAverage = false
) {

  // Valid assignments should never allow fewer than 0 points
  var absoluteMinimum = 0; 2

  // Valid assignments should never exceed more than 100 possible points
  var absoluteMaximum = 100; 2

  if (minimumBound < absoluteMinimum) return false; 3
  if (maximumBound > absoluteMaximum) return false; 3

  let min = Math.min(...grades);
  let max = Math.max(...grades);

  if (useAverage) {
    min = max = grades.reduce((acc, curr) => acc + curr, 0)/grades.length;
  }

  if (min < minimumBound) return false;
  if (max > maximumBound) return false;
  return true;
}
1

Renaming the parameter to describe its role.

2

Magic numbers are appropriately named for added context.

3

Further simplifying logic by splitting up the complex conditional into two simpler if statements.

Extracting Self-Contained Logic

Next, we can extract the average calculation into a separate function, as shown in Example 1-5.

Example 1-5. A small sample with more functions with clear responsibilities
function checkValid(
  minimum,
  maximum,
  grades,
  useAverage = false
){
  // Valid assignments should never allow fewer than 0 points
  var absoluteMinimum = 0;

  // Valid assignments should never exceed more than 100 possible points
  var absoluteMaximum = 100;

  if (minimumBound < absoluteMinimum) return false;
  if (maximumBound > absoluteMaximum) return false;

  let min = Math.min(...grades);
  let max = Math.max(...grades);

  if (useAverage) {
    min = max = calculateAverage(grades);
  }

  if (min < minimumBound) return false;
  if (max > maximumBound) return false;
  return true;
}

function calculateAverage(grades) { 1
  return grades.reduce((acc, curr) => acc + curr, 0)/grades.length;
}
1

Extracted average calculation into a new function.

As we iterate on our solution, it becomes more obvious that the logic to handle the average of the set of grades seems increasingly out of place. Next, we’ll continue to improve our function by creating two functions: one that verifies that the average of a set of grades fits within a set of bounds and another that verifies that all grades within a set occur within a minimum and a maximum value. We could reorganize the code into more focused functions at this point in a number of ways. There is no right or wrong answer so long as we’ve found a way to divorce the logic for the two distinct cases effectively. Example 1-6 shows one such way of further simplifying our checkValid function.

Example 1-6. A small sample with better-defined functions
function checkValid(
  minimum,
  maximum,
  grades,
  useAverage = false
){

  // Valid assignments should never allow fewer than 0 points
  var absoluteMinimum = 0;

  // Valid assignments should never exceed more than 100 possible points
  var absoluteMaximum = 100;

  if (minimumBound < absoluteMinimum) return false;
  if (maximumBound > absoluteMaximum) return false;

  let min = Math.min(...grades);
  let max = Math.max(...grades);

  if (useAverage) {
    return checkAverageInBounds(minimumBound, maximumBound, grades); 1
  }

  return checkAllGradesInBounds(minimumBound, maximumBound, grades); 2
}

function calculateAverage(grades) {
  return grades.reduce((acc, curr) => acc + curr, 0)/grades.length;
}

function checkAverageInBounds(
  minimumBound,
  maximumBound,
  grades
){ 1
  var avg = calculateAverage(grades);
  if (avg < minimumBound) return false;
  if (avg > maximumBound) return false;
  return true;
}

function checkAllGradesInBounds(
  minimumBound,
  maximumBound,
  grades
){ 2
  var min = Math.min(...grades);
  var max = Math.max(...grades);

  if (min < minimumBound) return false;
  if (max > maximumBound) return false;
  return true;
}
1

Extract logic to determine whether the average of the grades is within minimum and maximum bounds in its own function.

2

Extract logic to determine whether all the grades are within minimum and maximum bounds in a separate function.

Ta da! We’ve successfully refactored checkValid in six simple steps.

Our new version has some clear benefits. With just a glance, we can develop a solid sense of what the code aims to do. We’ve also made it the slightest bit more performant and simplified bug-prone logic by simplifying our conditions. All in all, the next developer is more likely to be able to extend on this solution without too much trouble. This is just a sneak peak into the potentially positive impact strategic refactoring at a microscopic level can have on your application; now imagine the impact that it can have when applied at scale.

But before we can sit down at our keyboards and start diligently refactoring, we need to orient ourselves properly. We need to understand the history of the code we want to improve, and for that, we need to understand how code degrades.

Get Refactoring at Scale now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.