2. How Code Degrades – Refactoring at Scale

Chapter 2. How Code Degrades

Successfully running a marathon is an impressive feat. While I’ve personally never taken on the challenge, quite a few of my friends have. What may surprise you, however, is that the large majority of these friends were not avid runners before deciding to sign up for their first half or full marathon. By sticking to a regular, sustainable training schedule, they were able to build up the necessary endurance in just a few months.

Most of my friends were already in good physical shape, but if your goal is to run a marathon and most of your current physical activity involves getting up from the couch to grab a bag of chips from your pantry, you will have a much more difficult time. Not only will you first have to build up the cardiovascular and physical endurance of a regularly active person, you’ll have to adopt new habits around habitual exercise and eating healthy food (even when all you want to do is settle into a comfy chair with a big, cheesy slice of pizza).

Small fluctuations in training can lead to serious setbacks. If you haven’t gotten enough sleep or get caught off-guard by a scorching-hot day, you will tire more quickly, compromising your ability to run your target distance. Even in peak marathon form, you have to be prepared for the unknowns on the day of the race. It might rain; your laces might break; you might be stuck in a tight crowd of runners. You learn to master the variables you can control but must be willing and ready to think on your feet.

Being a programmer is a little bit like being a marathon runner. Both take sustained effort. Both build atop preceding progress, commit by commit, mile by mile. Making an earnest effort to maintain healthy habits can make the difference between being able to get back into marathon-running shape or peak development pace in a matter of weeks and having to take months to do so. Maintaining a high level of vigilance over both your internal and external environments and adjusting accordingly is key to completing the race successfully. The same can be applied to development: a high level of vigilance over the state of the codebase and any external influences is key to minimizing setbacks and ultimately ensuring a smooth path to the finish line.

In this chapter, we’ll discuss why understanding how code degrades is key to a successful refactoring effort. We’ll look at code that is either stagnant or in active development and describe ways in which each of these states can experience code degradation, with a few examples pulled from both recent and early computer science history. Finally, we’ll discuss ways in which we can detect degradation early, and how we might prevent it altogether.

Why Understanding Code Degradation Matters

Code has degraded when its perceived utility has decreased. What this means is that the code, while once satisfactory, either no longer behaves as well as we would like or isn’t as easy to read or use from a development perspective. It’s for these precise reasons that degraded code is a great candidate for refactoring. That said, I firmly believe that you cannot set out to improve something until you have a solid grasp of its history.

Code isn’t written in a vacuum. What we might deem to be bad code today was likely good code when it was originally written. By taking the time to understand the circumstances under which the code was originally written, and how, over time, it might have gone from good to bad, we can build a better awareness of the core problem, get a sense of the pitfalls to avoid, and, thus, have a better shot at taking it from bad back to good.

Broadly speaking, there are two ways in which code can degrade. Either the requirements for what the code needs to do or how it needs to behave have changed, or your organization has been cutting corners in an attempt to achieve more in a short period. We’ll refer to these as “requirement shifts” and “tech debt,” respectively.

I believe it’s important not to assume that all code degradation you run into is due to tech debt, which is why we’ll first take a look at the many ways requirement shifts can make code appear worse over time. We all have those moments when we’ll come across some particularly dreadful code and think, “Who wrote this? How could we let this happen? Why has no one fixed this?” If we begin to refactor it immediately, we risk crafting a solution that overemphasizes what we find most urgently frustrating about the code, rather than addressing its truer, core pain points. It’s important to build empathy for the code by asking ourselves to identify what has changed since it was written. If we make an effort to seek the initial good, we gain an appreciation for the pitfalls the original solution avoided, the clever ways it might have dealt with a set of constraints, and produce a refactored result that captures all these insights.

Unfortunately, there are times when we simply have to do our best, given very limited resources. When we don’t have enough time or money to create a better solution, we start cutting corners and accruing tech debt. While the initial impact of that debt might be minimal, its added weight on our codebases can build up significantly over time. It’s easy to dismiss tech debt as bad code, but I challenge you to reframe it. Sometimes the scrappiest solution is the one that gets your product or feature to market the fastest; if getting your product into the hands of users is critical to your company’s survival, then the tech debt might very well be worth it.

As you read through the ways in which code can degrade, I encourage you to try to find examples of each of these in the code you work with most regularly. You might not be able to find an example for everything, but the process of searching for the symptoms of code degradation might lead you to develop a new perspective on the pieces of your application you’ve found most frustrating to work with.

Note

Once you’ve pinpointed code you’d like to refactor you will gain valuable insight into the how and why of the original authors’ initial solution if you can sit down with them. Oftentimes, they’ll be able to tell you immediately why the code degraded. If the authors say something along the lines of, “we didn’t know that…,” or, “at the time, we thought…,” you likely have a case of code degradation due to requirement shifts. On the other hand, if the authors say something like, “oh, right, that code was never any good,” or, “we were just trying to meet a deadline,” you know that you’re probably dealing with a standard case of tech debt.

Requirement Shifts

Whenever we write a new chunk of code, we ideally spend some time explicitly defining its purpose and providing thorough documentation to demonstrate intended usage. While we might try our best to anticipate any future requirements and attempt to design nimble systems able to handle these new demands, it’s unlikely we’ll be able to predict everything coming down the pipe. It’s only natural that the environments around our applications will change unpredictably over time. These changes can affect both code that is in active development and code that has been left untouched to different degrees. In this section, we’ll discuss a few ways in which the demands placed on our code might exceed its abilities, using examples from codebases under active and inactive development.

Scalability

One requirement we frequently attempt to estimate is the direction and degree to which our product needs to scale. This laundry list of requirements can get rather lengthy and include a wide range of parameters. Take, for instance, a simple application programming interface (API) request to create a new user entry in a system. We might set some guidelines around the expected latency of the request, the number of database queries executed within the request, the total number of new user requests allowed per second, and so on.

When launching a new product, one of our first assumptions deals with how many users we expect to use it. We craft a solution we think will comfortably handle that number (give or take a safe margin of error) and ship it! If our product is successful, we can end up with exponentially more users than we initially anticipated, and while that’s certainly an amazing situation to be in from a business perspective, our original implementation probably won’t be able to handle this new, unanticipated load. The code itself may not have changed, but it has effectively regressed due to a drastic shift in scalability requirements.

Accessibility

Every application should strive to be as accessible as possible from day one. We should use color-blind-friendly color schemes, add alternative text for images and icons, and ensure that any interactive elements are accessible via the keyboard. Unfortunately, teams hastening to ship a new product or feature often gloss over accessibility in favor of a more aggressive launch date. While shipping new features might help you retain current users and attract new ones, if these features aren’t accessible to a subset of your anticipated user base, you risk alienating them. The second your product becomes inaccessible to some, its perceived utility substantially diminishes.

Although few iterations on official best practices for web accessibility have been developed by the Web Accessibility Initiative (WAI) since 1999, a number of important revisions have been standardized. With every new iteration, developers of active websites and applications must revisit code sometimes long untouched and implement any necessary changes to comply with the newest standards. Iterations on accessibility standards can decrease the quality of your application.

Device Compatibility

Every year, hardware companies release new versions of their devices; sometimes, they’ll even take things a step further and introduce an entirely new class of device. Among smartphones, smart watches, smart cars, and smart TVs, we are constantly playing catch-up, attempting to repackage our applications to work seamlessly on the latest hardware. Users have grown to expect that their favorite applications work on a variety of platforms. If you’re a developer for a popular mobile game and a major hardware company releases a new device with a higher screen resolution, you risk losing a significant portion of your user base unless you ship a new version of your game built to handle the larger screen.

Environmental Changes

When changes occur in a program’s environment, all sorts of unexpected behavior can begin to manifest. Before the age of modern gaming computers loaded with powerful graphics processing limits (GPUs) and dozens of gigabytes of random-access memory (RAM), we had humble, little gaming consoles housed in arcades and, later, our living rooms. Game developers devised clever ways to use the limited hardware available to them to build classics like Space Invaders and Super Mario Bros. At the time, it was standard practice to use the central processing unit (CPU) clock speed as a timer in the game. It provided a steady, reliable measure of time. While this wasn’t a problem for console games, where the cartridges often weren’t compatible with newer, more powerful iterations of the console, it became a rather serious oversight for games running on personal computers. As clock speed on newer computers increased, so did the speed of gameplay. Imagine having to stack Tetris pieces or avoid a stream of Goombas at twice the normal speed; at a certain point, the game becomes wholly unusable. In both of these examples, the requirement was that the code was run on specific physical hardware; unfortunately, the hardware has since changed dramatically, and as a result, the code has degraded.

These types of environmental changes are still a serious concern today. In January 2018, security researchers from Google Project Zero and Cyberus Technology, in collaboration with a team at the Graz University of Technology, identified two serious security vulnerabilities affecting all Intel x86 microprocessors, IBM POWER processors, and some Advanced RISC Machine (ARM)-based microprocessors. The first, Meltdown, allowed rogue processes to read all memory on a machine, even when unauthorized to do so. The second, Spectre, allowed attackers to exploit branch prediction (a performance feature of the affected processors) to reveal private data about other processes running on the machine. You can read more about these vulnerabilities and their inner workings on the official website.

At the time of the disclosure, all devices running any but the most recent versions of iOS, Linux, macOS, and Windows were affected. A number of servers and cloud services were affected, as well as the majority of smart devices and embedded devices. Within days, software workarounds became available for both vulnerabilities, but these came at a performance cost of 5 to 30 percent, depending on the workload. Intel later reported it was working to find ways to help protect against both Meltown and Spectre in its next lineup of processors. Even the things we believe to be most stable (operating systems, firmware) are susceptible to changes in their own environments; and when these core, underlying systems on top of which we run countless applications are affected, we, in turn, are affected.

External Dependencies

Every piece of software has external dependencies; to list just a few examples, these can be a set of libraries, a programming language, an interpreter, or an operating system. The degree to which these dependencies are coupled to the software can vary. This reliance isn’t anything new; many influential programs from the early days of artificial intelligence research were developed in Lisp and Lisp-like research programming languages as they were actively developed in the 1960s and early 1970s. SHRDLU, an early natural language–understanding computer program, was written in Micro Planner on a PDP-6, using nonstandard macros and software libraries that no longer exist today, thus suffering from irreparable software rot.

Today, we do our best to update our external dependencies to keep up to date with the latest features and security patches. Sometimes, however, we either deprioritize or lose track of updates, especially when it comes to code we’re not actively maintaining. While allowing dependencies to fall a few versions behind might not be an immediate problem, it does come at a risk. We become more susceptible to security vulnerabilities. We also open ourselves up to potentially difficult upgrade experiences at a later date.

Say we are running a program that relies on version 1.8 of an open-source library called Super Timezone Library. Just a few weeks after releasing version 4.0, the developers of Super Timezone Library announce that they will no longer actively support any versions below 3.0. We now need to upgrade to version 3.0 at the minimum to continue to port security patches. Unfortunately, version 2.5 introduced some backward-incompatible changes and version 2.8 deprecated functionality used widely in our application. What could have been a small, regular investment in keeping the library up to date over the past few years has now turned into a much more complex, urgent investment.

Unused Code

Changes in requirements can lead to unused code. Take, for example, a publicly facing API. Your team decides to deprecate the API and warn third-party developers of the upcoming change. Unfortunately, after you’ve communicated the intended change, removed the documentation from your website, and ensured that no external systems were still relying on the endpoint, your team forgets to remove the code. A few months later, a new engineer begins implementing a new feature, stumbles upon the decommissioned API endpoint, and assumes, quite naturally, that it is still functional. They decide to repurpose it for their own use case. Unfortunately, they quickly find out that the code doesn’t do quite what they intended, simply because the API had been left in the dust and hadn’t adapted with the rest of the codebase and numerous iterations of requirement changes.

Unused code can also be problematic from a developer productivity perspective. Every time we encounter code we believe to be unused, we have to determine very carefully whether we can safely remove it. Unless we’re equipped with reliable tooling to help us properly highlight the extent of the dead code, we might have a difficult time pinpointing its exact boundaries. If we aren’t sure whether we can delete it, usually we’ll just move on and hope someone else can figure it out later on. Who knows how many engineers will come across the same piece of code and ask themselves the same question before it’s finally removed!

Finally, unused code, if allowed to pile up, can be a hindrance to performance. If, for example, your team works on the client-facing portion of a website, the size of the files the JavaScript files requested by your browser directly translates to initial page load times. Typically, the larger the file, the slower the response. Greedily requesting bloated application code can be quite detrimental to the user experience.

Changes in Product Requirements

Most of the time, it’s easier to write a solution for today or tomorrow’s product requirements, solving for the problems and constraints we understand and can easily anticipate, than to write one for next year, attempting to solve for unknown future pitfalls. We try to be pragmatic, weighing current concerns against future concerns, and attempting to determine how much time we should invest in solving for either. Sometimes, we simply don’t have a good intuition about the future.

Boolean arguments to functions are a great example of the difficulty of predicting future product requirements in action. Most of the time, Boolean arguments are introduced to existing functions to modify their behavior. (We saw one in “Our First Refactoring Example”, where a Boolean flag was used to decide whether we wanted to know whether each of the grades or the average of those grades fell in a given range.) Adding a Boolean flag is often the smallest, simplest change you can make when you find a function that does almost exactly what you want it to do, with just a tiny exception. Unfortunately, this type of change can cause all sorts of problems down the line. We can see some of those in action in Example 2-1, where we have a small function responsible for uploading an image given a filename and a flag denoting whether the file is a PNG.

Example 2-1. A function with a Boolean argument
function uploadImage(filename, isPNG) {
  // some implementation details
  if (isPNG) {
    // do some PNG-specific logic
  }
  // do some other things
}

What if, a few months from now, we decide to support a new image format? We might decide to add another Boolean argument to designate isGIF, as shown in Example 2-2.

Example 2-2. A function with two Boolean arguments
function uploadImage(filename, isPNG, isGIF) { 
  // some implementation details
  if (isPNG) {
    // do some PNG-specific logic
  } else if (isGIF) { 
    // do some GIF-specific logic
  }
  // do some other things
}

Introduced a new Boolean argument to designate whether the image is a GIF.

An image cannot be both a PNG and a GIF, so we’ve added an else if here.

To call this function and correctly upload a GIF, we would need to remember to set the second Boolean argument to true. Readers who come across the code calling out to uploadImage would likely be confused and need to refer to the function definition to understand what role the two Boolean arguments play.

Note

In a language with named arguments, we would be less concerned with needing to reference the function definition to know the role and order of arguments. Regardless of language choice, it remains that while uploadImage(filename=filename, isPNG=true, isGIF=true) may seem nonsensical, it is a perfectly valid function call (and is very likely to cause bugs in the future). Example 2-3 shows an example where it might be difficult for the reader to discern what uploadImage does given the context.

Example 2-3. A function uploading a GIF
function changeProfilePicture(filename) {
  // some implementation details
  if (isAnimated) {
    uploadImage(filename, false, true); 
  } else {
    uploadImage(filename, true, false); 
  }
  // do some other things
}

Here we are uploading a GIF.

Otherwise, we are uploading a PNG.

Not only is it difficult for developers to understand how uploadImage works when reading through functions like changeProfilePicture, it’s an unsustainable pattern to continue to maintain if more image formats are introduced in the future. The developer who added the first Boolean argument to support isPNG was mostly concerned with today’s problems rather than those of tomorrow. A better approach would be to split up the logic into distinct functions: uploadJPG, uploadPNG, and uploadGIF, as shown in Example 2-4.

Example 2-4. Distinct functions for uploading different types of files
function uploadImagePreprocessing(filename) {
  // some implementation details
}

function uploadImagePostprocessing(filename) {
  // do some other things
}

function uploadJPG(filename) {
  uploadImagePreprocessing();
  // do JPG things
  uploadImagePostprocessing();
}

function uploadPNG(filename) {
  uploadImagePreprocessing();
  // do PNG things
  uploadImagePostprocessing();
}

function uploadGIF(filename) {
  uploadImagePreprocessing();
  // do GIF things
  uploadImagePostprocessing();
}

Now you might be wondering why adding the isPNG Boolean argument is a serious problem if we can just refactor it later. To replace all occurrences of uploadImage properly, we’d need to audit each callsite individually and replace it with either uploadJPG or uploadPNG, depending on whether the Boolean argument is set to true. Because these changes are manual but mundane, the likelihood of us making the wrong replacement is quite high and could lead to some serious regressions. Depending on how widespread the problem might be, and how tightly coupled it might be to other crucial business logic, refactoring what seems like a simple Boolean argument might be a daunting task.

Tech Debt

The most common culprits behind tech debt are limited time, limited numbers of engineers, and limited money. Given that all technology companies are faced with limited resources on one or more axes, each and every one of them has tech debt. Tiny, six-month-old startups; giant, decades-old conglomerates; and every company in between has a fair share of crufty code. In this section, we’ll take a closer look at how these influences can lead to the accumulation of tech debt. Although it can be easy to point a finger at the original authors of the code and admonish them for making decisions that appear suboptimal today, it’s important to remember that they were operating under serious constraints. We have to acknowledge that sometimes it’s just about impossible to write good code under tight pressure.

Working Around Technology Choices

When implementing something new, we have to make some critical decisions about which technologies we want to use. We have to choose a language, a dependency manager, a database, and so on. There’s a fairly long laundry list of decisions to make well before the application becomes available to any users. Many of these decisions are made given the engineers’ experience; if these engineers are more comfortable using one technology over another, they’ll have an easier time getting the project up and running quickly than if they decided to adopt a new stack.

Once the project’s been launched and found some traction, these early technology decisions are put to the test. If a problem with a technology choice arises early enough in the lifetime of the application, it might be easy and inexpensive to find an appropriate alternative and pivot to it, but oftentimes the limitations of those choices don’t become apparent until well after the application has grown past this point.

One such decision might be to develop an application by using a dynamically typed programming language instead of a statically typed programming language. Proponents of dynamically typed programming languages argue that they make the code easier to read and understand; less indirection around strictly defined structures and type declarations allow the reader to understand better and more readily the purpose of the code. Many also tout the quicker development cycle they provide due to the lack of compile time.

While there are many upsides to using dynamically typed programming languages, they become difficult to manage when applications grow beyond a critical mass. Because types are only verified at runtime, it is the developer’s responsibility to ensure type correctness by writing a full suite of unit tests that exercises all execution paths and asserts expected behavior. New developers seeking to familiarize themselves with how different structures interact with one another might have a difficult time doing so if variable names do not immediately indicate which type it might be. It’s not uncommon to end up needing to program defensively, as shown in Example 2-5, where we assert that a value passed into a function has certain properties and isn’t unintentionally null.

Example 2-5. Defensive programming in action
function addUserToGroup(group, user) {

  if (!user) {
    throw 'user cannot be null';
  }

  // assert required fields
  if (!user.name) {
    throw 'name required';
  }

  if (!user.email) {
    throw 'email required';
  }

  if (!user.dateCreated) {
    throw 'date created required';
  }

  // assert no empty strings or other invalid values
  if (user.name === "") {
    throw 'name cannot be empty';
  }
  if (user.email === "") {
    throw 'email cannot be empty';
  }
  if (user.dateCreated === 0) {
    throw 'date created cannot be 0';
  }

  group.push(user);
  return group;
}

It’s very likely the author of the code sample runs into issues regularly with invalid users weaving their way through a callstack at runtime simply due to the dynamic nature of JavaScript. The author just wants to be certain that they are only adding valid users to the group, and that’s completely understandable. Unfortunately, now addUserToGroup is primarily concerned with ensuring that the user provided is valid, rather than adding the user to the group. As more decisions are made about what constitutes a valid user, each of these ad hoc validations sprinkled throughout the codebase needs to be updated. There’s also an increasing chance we might introduce a bug by simply forgetting to update one such location. Eventually, we end up with lengthy, convoluted, bug-prone functions everywhere.

We can introduce a new function to help mitigate code degradation. Let’s say we write up a simple helper to encapsulate all the logic for validating a user object; we’ll call it validateUser. Example 2-6 shows its implementation.

Example 2-6. A simple helper function to encapsulate user validation logic
function validateUser(user) {
  if (!user) {
    throw 'user cannot be null';
  }

  // assert required fields
  if (!user.name) {
    throw 'name required';
  }

  if (!user.email) {
    throw 'email required';
  }

  if (!user.dateCreated) {
    throw 'date created required';
  }

  // assert no empty strings or other invalid values
  if (user.name === "") {
    throw 'name cannot be empty';
  }
  if (user.email === "") {
    throw 'email cannot be empty';
  }
  if (user.dateCreated === 0) {
    throw 'date created cannot be 0';
  }

  return;
}

We can then update addUserToGroup to use our new helper function, drastically simplifying the logic, as shown in Example 2-7.

Example 2-7. Simplified addUserToGroup function without inlined validation logic
function addUserToGroup(group, user) {
  validateUser(user);
  group.push(user);
  return group;
}

Unfortunately, while it’s much easier for us to call validateUser, replacing all the locations where we previously enumerated each check will be an easy task. First, we have to identify each of those spots. If we’re dealing with a large codebase, that might be a daunting task. Second, in auditing each of these locations, we’ll probably end up finding a handful of instances where we’ve forgotten a check or two. In some cases, this is a bug, and we can safely replace the checks with a single call to validateUser; in other cases, this might have been intentional, and we cannot blindly replace the existing code with our new helper at the risk of introducing a regression. As such, easing the burden of our defensive programming requires us to plan and execute a sizable refactor.

Persistent Lack of Organization

Maintaining an organized codebase is a little bit like maintaining a tidy home. It seems as though there’s always something more important to do than to put away the clothes heaped over the dresser or sort through the stack of mail accumulating on the coffee table. But the more we accumulate, the more time we’ll spend combing through it all when we finally get around to it. You might even allow the clutter to build up to the point that it’s begun overflowing on to other surfaces. My parents were onto something when they encouraged me to keep things tidy and clean up just a little bit every day; they knew that it was always much easier to take care of a small mess than a massive one.

Many of us fall into the same patterns when it comes to keeping our codebases organized. Take, for instance, a codebase with a relatively flat file structure. Most of the code is organized into two dozen or so files, with a single directory for tests. The application grows at a steady pace, with a few new files added every month. Because it’s easier to maintain the status quo, instead of proactively beginning to organize related files into directories, engineers instead learn to navigate the increasingly sprawling code. New engineers introduced to the growing chaos raise a warning flag and encourage the team to begin splitting up the code, but these concerns fall on deaf ears; managers encourage them to focus on the deadlines looming ahead, and tenured engineers shrug and reassure them that they’ll quickly figure out how to be productive in the disarray. Eventually, the codebase reaches a critical mass in which the persistent lack of organization has dramatically slowed productivity across the engineering team. Only then does the team take the time to draft a plan for grooming the codebase, at which point the number of variables to consider is far greater than it would have been, had they made a concerted effort to tackle the problem months (or even years) earlier.

Moving Too Quickly

Rapid iteration and product development can swiftly degrade software quality if not kept in check. When building out new product features under aggressive deadlines, we tend to cut corners: we’ll omit a few test cases, give variables generic names, or add a few if statements where we could have made a new function. If we do not properly make note of the corners we’ve cut and allocate the time necessary to correct them immediately after we’ve met our target deadline, they pile up. Soon, you end up with exceedingly lengthy functions, littered with branching logic and little-to-no unit test coverage sprinkled throughout your codebase. When working in more complex applications, where multiple teams are iterating on distinct features alongside one another, effects of moving too quickly begin to compound. Unless every team can communicate product changes effectively with every other team, the amount of cruft piles up. You can see an example of that compounding effect illustrated in Figure 2-1.

Many of us working on modern applications practice continuous integration and delivery; we merge our changes back into the main branch as often as possible, where they’re validated by running automated tests against a new build of the application. We ensure that customers aren’t exposed to half-baked features and partial bug fixes by gating these changes behind feature flags (otherwise known as feature toggles). While these give us a good amount of flexibility during active development, they’re easy to forget about once we’ve successfully introduced the change to all the users.

Figure 2-1. A graph of cruft accumulation over time

Every company I’ve worked for had dozens (if not hundreds) of feature flags still being referenced in the program well after they’d been enabled for all of production. While it might seem benign to leave a few of these checks lying around, there are some distinct risks.

First, it causes added cognitive load on developers reading the code; if the developer doesn’t take the time to verify the status of the feature, they might be misled into thinking it is still under active development and only make an important change in the nongated codepath. Second, it can be frustrating to spend time determining whether the feature is active in production, only to find out that it’s been live to everyone for weeks. In the severe cases where there are hundreds of essentially defunct feature flags, this can have a very serious performance impact on the application. The cumulative time spent validating each feature-related conditional for a given request or codepath can be significant. We might all see some performance enhancements by cleaning up our obsolete flags.

Applying Our Knowledge

Code degradation is inevitable. No matter how hard we try to avoid them, there will be shifts in requirements our applications will need to adapt to. We can try to minimize development under pressure, but sometimes we need to cut corners to ship quickly and give our business the competitive advantage. If code degradation is inevitable, then refactoring at scale is equally inevitable. There will always be a need for us to address tricky, systemic problems in our codebases. If we think we’ve reached the point that we think the degradation is just too burdensome and preventing our engineering team from developing as well as it could, then we need to put on our hard hats and figure out both why and how we got to this point.

When we learn to see beyond code’s immediate problems and instead seek to understand the circumstances under which it was originally written, we begin to see that code isn’t inherently bad. We build empathy and use this newfound perspective to identify the code’s true foundational problems and hatch a plan to improve it in the best way possible. Think of this process as just one big exercise in code archaeology!

Now that we’ve learned how code degrades, we have to learn how to quantify it properly for others to understand. We have to use our hunch that the degradation is at a critical point, our knowledge about why and how it got to that point, to figure out the best way to distill the problem into a set of metrics we can use to convince others that this is, in fact, a serious problem. The next chapter discusses a number of techniques you can use to measure problems in your codebase and establish a solid baseline for your refactoring effort.