Skip to content
FollowtheDamnCode

Refactoring Safely

Change a program's structure without changing what it does — in small, tested steps you can stop between at any time.

Big idea

Refactoring is changing code's structure without changing its behaviour, in steps so small that each one is obviously safe. The discipline is the point: tests green → one small move → tests green. Rewrites gamble; refactoring compounds.

The problem it solves

Everyone has seen the "big cleanup" branch: two weeks of moving things around, 4,000 changed lines, and a merge that breaks three features nobody can trace. Or the opposite: code nobody dares touch, so every new feature is bolted on the side and the mess grows. Refactoring is the middle path — improve the design continuously, in moves small enough to review, test, and ship the same day.

Think of it like

Rearranging a furnished room while living in it. You don't empty the house and rebuild; you move one bookcase, check you can still reach the door, then move the next. At every point the room is usable, and you can stop when it's good enough.

The loop

Small steps, always green
  1. Have a safety net. Tests that pin current behaviour — even rough "characterisation tests" that just record today's output.
  2. Name the smell (Bloaters, Couplers, …) and the refactoring that addresses it.
  3. Take the smallest step: rename one thing, extract one function, move one method.
  4. Run the tests. Green: commit. Red: undo — don't debug a refactoring.
  5. Never mix refactoring with behaviour changes in one commit. "Make the change easy, then make the easy change."

In code: characterisation tests

When old code has no tests, record what it does now, then refactor until the recordings still match.

Pin the old behaviour, refactor, compare
def legacy_grade(s):                         # nobody remembers why it's written like this
    g = ""
    if s >= 90: g = "A"
    if s < 90 and s >= 80: g = "B"
    if s < 80 and s >= 70: g = "C"
    if s < 70: g = "F"
    if s > 100 or s < 0: g = "?"
    return g

def grade(score):                            # refactored: guard clause, ordered thresholds
    if not 0 <= score <= 100:
        return "?"
    for floor, letter in ((90, "A"), (80, "B"), (70, "C")):
        if score >= floor:
            return letter
    return "F"

inputs = [-5, 0, 69, 70, 79, 80, 89, 90, 100, 101]
recorded = {s: legacy_grade(s) for s in inputs}          # the characterisation
mismatches = [s for s in inputs if grade(s) != recorded[s]]
print("recorded:", "".join(recorded[s] for s in inputs))
print("refactor:", "".join(grade(s) for s in inputs))
print("mismatches:", mismatches or "none")
output
recorded: ?FFCCBBAA?
refactor: ?FFCCBBAA?
mismatches: none

Run and edit it in the app.

Pick inputs at the edges (69/70, 100/101) — that's where restructured conditions usually go wrong.

Which change is a refactoring?

Put a safe refactoring step in order.

Watch it

When to refactor — and when not to

  • Before a change, to make it easy ("preparatory refactoring").
  • While reading, to make code say what you just figured out ("comprehension refactoring").
  • After it works, to clean up before you move on.

Don't refactor code you're about to delete, code with no safety net you can't build, or right before a deadline on a path you won't touch again. And don't call a rewrite a refactoring.

Pros and cons

ProsCons
Design improves continuously, at low riskNeeds tests (or the discipline to write some)
Every step is reviewable and shippableSmall steps feel slow at first
Understanding goes into the code, not a wikiEasy to drift into behaviour changes

How it relates

Key terms

  • Refactoring — Restructuring code without changing its observable behaviour.
  • Characterisation test — A test that records what existing code does now, so changes can be checked against it.
  • Preparatory refactoring — Reshaping code first so the upcoming change becomes simple.
  • Rewrite — Replacing code wholesale; riskier and not a refactoring.