Fixing Spaghetti: How to Work With Legacy Code
What is Legacy Code?
Legacy code is software that generates value for a business but is difficult for developers to change. The terms "code rot" and "spaghetti code" refer to legacy code that is tangled up in poor quality. Over time, as "get it done now" is continually favored over "get it done right", the code base decays by avoiding industry best practices, making it tightly coupled, difficult to test, and increasingly prone to defects.
The longer this goes on, the more frustrated customers get with the software due to quirky defects, bad user experiences and long lead times for changes. Developers are afraid to make changes due to the "Jenga effect" -- as one piece of code is changed or removed, it often leads to new defects being introduced in the system in sometimes seemingly unrelated places. This compounds into what is known as "technical debt".
Just like financial debt, technical debt is sometimes a calculated risk. For example, you may incur technical debt in order to get a software product to market faster. However, just like financial debt, the longer this goes unpaid, especially if it continues to grow, the more it hinders your technical flow. Just like cash flow, technical flow is critical to the health of an organization. Technical flow is the ability for developers to respond quickly to change.
Why Does Spaghetti Code Happen?
No developer sets out to write spaghetti code. As Brett from Pulp Fiction said, "We got into this thing with the best intentions."
Given enough time, any tech decision can turn out to be a bad one. Software evolves over time based on new understandings of the business domain, better practices, and more efficient ways of solving problems. Good developers are always learning -- you will probably hate the code you wrote today one year from now. ;)
Warning Signs of Spaghetti Code
So how do you know if your code base is spaghetti? You will notice one or more of these side effects:
-
Estimates from developers seem to be considerably large. This is caused by the uncertainty around unreadable code and knowing that unknown unknowns will inevitably arise.
-
Knowledge silos: only one developer or certain contractors can work on the code base -- no one wants to own or touch certain parts of the code
-
High turnover rates (due to developer frustration)
-
Developers bond over phrases like "it was like that when I got here"
-
Changes to code result in new defects
-
"I thought we fixed this before" -- old defects are re-introduced
-
Copy and pasted code
-
Long methods/functions
-
Classes that scroll on forever
-
More than 3 parameters per function
-
Comment-heavy code (long-winded explanations on WTF is going on with the code)
-
Jenga effect: developers fear changing code
-
Talk of "The Big Rewrite"
The Big Rewrite?
When developers talk about The Big Rewrite, it is the biggest sign that they are working with spaghetti code. In his famous blog post "Things You Should Never Do", Joel Spolsky goes so far as to say it is "the single worst strategic mistake that any software company can make". There are counter arguments that argue for some edge cases, but most seasoned developers would agree with Joel.
The Big Rewrite will be extremely expensive and, in accordance with Hofstadter's Law, will certainly take way longer than you think. Estimates for The Big Rewrite are typically off by a factor of 4, due to the cone of uncertainty. In my experience, one project took 5 developers 18 months and another took 2 developers 24 months.
The legacy code may start out with poor quality, but by fixing it iteratively over time, the business can sustainably function without having to wait for The Big Switch from The Big Rewrite. Changes are subtly made, allowing the system to safely evolve into a better architecture.
The Big Rewrite falls into a classic waterfall management trap: "if we plan for everything up front, and design it just right... the new system will be able to handle anything". The reality is that most systems are too complex for one person to understand in such a short period of time. By the time you finish the requirements, they will already be out of date -- tech and modern business both move too fast. To counteract this ever changing list of needs, you have to focus instead on agility and continual improvement.
A code base is way more complex than you imagine when you dream of The Big Rewrite. This is why you think of The Big Rewrite in the first place -- "this is too confusing to figure out -- let's rewrite it."
The code base has taken a long time to get to where it is -- there are a lot of edge cases you are not considering. There is a lot of business logic buried deep in the code. The unknown unknowns will force you into a death march project. It's not until months into the project that you will begin to realize just how in over your head you are. By then, you are back at square one -- rushing to get things done because of a looming, unachievable deadline.
The Big Rewrite will most likely result in lack of confidence in the development team, disappointed customers, and most likely a new system that is still buggy and void of features.
The reason The Big Rewrite is so tempting is because developers tend to prefer greenfield projects and newer technology. It is mentally taxing to work on a poorly-architected system that does not follow best practices.
So, How Do You Fix Spaghetti Code?
Control the Chaos
If it is not already, make sure the code base is under version control, such as git, so that any and all changes to the code can be tracked and rolled back if necessary.
Quarantine The Mess
The next step is to place the code base under an automated test harness. An automated test harness is a test framework that performs scripted tests on the code base to ensure that, given certain input, the output is always the same. For example, if you have a function called add(Integer a, Integer b), you may have tests that state “if given 3 and 2 as inputs, the output should be 5” or “if given 9 and 7 as inputs, the output should be 16”. The test harness will typically show green for all passing tests and red for failing tests.
Examples of automated test harnesses, or test suites, include JUnit for Java and PHPUnit and Codeception for PHP. A test harness removes the "Jenga effect" -- once a test harness is in place, the code can be refactored in a way that ensures the code continues to work as it did before the refactoring.
The initial tests should test what the system currently does, not necessarily what it's assumed to do. Only after the tests are in place should the requirements be clarified as to the actual intent of the system.
Refactor Relentlessly
Once the code is under version control and test harnesses are in place, begin refactoring.
Refactoring code is part of the kaizen philosophy of continually improving. Refactoring embraces the fact that we can't plan a perfect design upfront and that, given enough time, most architecture will crumble without routine maintenance. Just like your car, your code base needs regular tune ups.
As developers learn more about the business, the code should be refactored to reflect new knowledge. This is in adherence with domain-driven design, which places business knowledge at the center of software.
One thing to clarify here: do not ask for permission to refactor. Refactoring is a non-negotiable part of software development. A doctor does not ask to clean you up after surgery -- "hey, you can save 200 bucks if we leave you in bloody rags!". A restaurant does not ask you permission to wash your dishes before your meal. When you ask, you leave it open for removal. This is how spaghetti code is served up in the first place.
Follow The Boy Scout Rule
Every time you are in the code base, leave the code cleaner than you found it. Refactor a little here and there, every day. This leads to several wins over and over again that you can constantly analyze and adjust as more is learned about the system.
Keep It Super Simple
As you refactor more and more, the code base becomes easier and easier to change, and less and less defects will be present.
Always focus on quality and simplicity. Avoid the temptation to patch and band aid. Let's face it: "I'll fix it later" never happens. Do it right today.
Do The Dishes
Robert "Uncle Bob" Martin uses a great analogy to describe the importance of refactoring. Think about dinner: you can cook it faster if you avoid washing the dishes. Eventually, though, you will run out of dishes. Putting it off now just means you'll have more to do later -- or you'll eventually outsource it by going out to eat, and that always costs more than cooking it at home. ;)
Best Practices for Refactoring Code
-
Functions should be named descriptively such that the intent is easy to determine -- this allows developers to quickly scan code to look for sources of defects or where to add new features
-
Long names are fine (always prefer long function/methods to long-winded comments)
-
Functions should be easy to name -- if you can't name it something meaningful that describes the one thing it does, you probably have a function that is too big and needs to be split into two or more functions
-
Functions should do one thing and do it well (the Heinz principle)
-
When a function does one thing really well without any side effects, you will be more likely to reuse it more often
-
You will know a function is doing one thing well when you can no longer create any additional functions from it
-
Code inside the function should be at the same level of abstraction
-
If you are doing business logic, you shouldn't be doing string manipulation or database calls in the same function
-
Doing this allows you to easily read code from the top-most level of abstraction down, getting deeper if needed to pinpoint a source of error or change
-
Well-written functions should read like bullet points
-
Functions should be no more than 20 lines of code
-
Even 20 is a bit on the longer end -- strive for 10 lines or less
-
Much easier for the brain to scan and pinpoint problems this way
-
Forces code to be more modular
-
If a block of code is inside of a control statement (if/else/while, etc), that code should be moved into a function -- if function() else someOtherFunction()
-
Functions should have a max of 3 parameters, ideally 0 or 1
-
It's hard to remember the order of arguments when you have more than 3, making it more prone to defects and hard to read
-
If you have 3 or more parameters, you are probably dealing with a structured object that should be passed in instead. For example login(String username, String password) could be changed to login(Credentials credentials) where Credentials is a structured object with a username and password, promoting reuse elsewhere.
-
Avoid boolean parameters -- instead of a function with a boolean turn(Boolean isOn), create two functions to clarify intent: turnOn() and turnOff()
-
Functions should throw exceptions rather than returning error codes or messages
At Ethode, we've been called in to stabilize some truly horrendous code. If you feel you are suffering from spaghetti code, and are not sure how to regain control of your project, contact us for a consultation.
If you enjoyed this post, you should check out the following excellent roundtable discussion on legacy code. I was invited to appear on The Frontier vidcast with host David Ledgerwood from gun.io and fellow guest Debbie Madden from Stride Consulting in NYC in April 2018. You can view the video here: https://www.gun.io/legacy-code