Refactoring & code smells
Restructuring code without changing its behaviour: small safe steps under tests, recognising common smells, and knowing when to stop.
13 cards · 7 quiz questions · 8 min read
Code is read and changed far more often than it is written. Every feature leaves traces of the understanding its authors had at the time, and as requirements shift, that structure stops fitting. Refactoring is the discipline of continuously reshaping code so that it keeps fitting, without breaking what already works.
What refactoring is (and is not)
Martin Fowler’s Refactoring gives the definition most engineers use: a change to the internal structure of software that makes it easier to understand and cheaper to modify, without changing its observable behaviour.
That last clause is what makes refactoring a distinct activity. Rewriting a module, changing an API, or fixing a bug while tidying up may all be worthwhile, but they are not refactorings. Fowler borrows Kent Beck’s image of two hats: at any moment you are either adding functionality or refactoring. You can swap hats often, but not wear both. If a test fails, you then know which kind of change caused it.
Small, safe steps
The catalogue in Fowler’s book is a list of tiny, named transformations: Rename Variable, Extract Function, Inline Function, Move Function, Introduce Parameter Object, Replace Conditional with Polymorphism, and many more. Each is small enough to do in a minute and check immediately.
The workflow looks like this:
- Make sure tests cover the behaviour you are about to touch.
- Apply one small transformation.
- Run the tests.
- Commit (or at least be able to undo), and repeat.
It can feel absurdly slow at first. It is actually fast, because you almost never spend time debugging: if something breaks, it was the last two-minute change, and you just undo it. Large structural changes emerge from long sequences of these steps, with the system working the whole time.
Tests are what make this safe. When working with untested legacy code, a common first move is to write characterisation tests that pin down what the code currently does, oddities included, before changing anything.
Code smells
Fowler and Beck coined code smells to describe surface symptoms that often point to deeper design problems. A smell is a hint to investigate, not a verdict. Some of the most useful:
Duplicated code
The same logic in several places means every change must be made everywhere, and eventually one copy is missed. Extract the shared logic. But check that the copies really represent the same idea; two things that happen to look alike today may need to evolve differently.
Long function
Long functions are hard to understand because the reader must hold too much in mind at once. The usual fix is Extract Function. A good heuristic: whenever you feel the need to write a comment explaining a block, that block probably wants to be a function whose name says what the comment would have said.
Feature envy
A function that reaches into another object’s data much more than its own probably lives in the wrong place.
// On Order, but almost entirely about the customer
function loyaltyDiscount(order) {
const c = order.customer;
return c.years > 5 && c.tier === "gold" ? 0.1 : 0;
}
Moving this onto the customer keeps behaviour next to the data it depends on.
Shotgun surgery
One conceptual change (say, supporting a new currency) forces small edits in a dozen files. A single responsibility has been scattered. Its mirror image is divergent change: one module that is edited for many unrelated reasons, meaning several responsibilities are crammed together. Both are symptoms of poor cohesion.
Primitive obsession
Using raw strings and numbers for domain concepts such as money, email addresses, or date ranges. Validation gets duplicated, units get confused (is that amount in pounds or pence?), and nothing stops you passing a phone number where an email is expected. A small type like Money or EmailAddress gives the concept a home for its rules.
When to refactor
Fowler argues that most refactoring should not be a separate project but part of everyday work:
- Preparatory refactoring: before adding a feature, reshape the code so the feature fits naturally. Beck’s summary is to make the change easy, then make the easy change.
- Comprehension refactoring: when you finally understand confusing code, rename and restructure so the next reader does not have to repeat the effort.
- Litter-pickup refactoring: leave the code a little better than you found it.
When not to refactor
Honest judgement matters here, because refactoring can become an end in itself.
- Code you do not need to change. Ugly but stable code that nobody touches costs little. Refactoring pays back through future changes, so focus where change is happening.
- When you cannot verify behaviour. Without tests or another way to check, “refactoring” is just risky editing. Add tests first, or leave it.
- When a rewrite is genuinely cheaper. Sometimes the code is beyond salvage, though this is rarer than it feels.
- At the wrong moment. A sweeping restructure the day before a release, or a huge unreviewable pull request mixing cleanup with features, adds risk without matching benefit.
Refactoring is an economic activity: it is worth doing when it makes the next change cheaper by more than it costs.
Key takeaways
- Refactoring changes structure, never observable behaviour; keep it separate from feature work.
- Work in tiny, tested steps so you are rarely more than a minute from working code.
- Smells such as duplication, long functions, feature envy, shotgun surgery and primitive obsession are prompts to look closer.
- Refactor where you are working, and skip it where the payoff is not there.
While "cleaning up" a function, you also fix an off-by-one bug. Was the whole change a refactoring?
Sources
- Martin Fowler — Refactoring: Improving the Design of Existing Code book Defines refactoring and catalogues code smells and refactorings; second edition uses JavaScript.
- Martin Fowler — martinfowler.com website Short essays on refactoring, preparatory refactoring and code smells.
- Kent Beck — Test-Driven Development: By Example book Refactoring as the third step of the TDD cycle.