Skip to content

Software Quality

Refactoring practice

A refactor reduces accidental complexity: the cognitive cost a program adds beyond what its problem requires. Behavior and delivered capability stay fixed.

That makes it a maintainability move, not a verification method: it reduces no uncertainty about whether the code is correct. Testing, type-checking, and formal verification are its complement.

Essential and accidental complexity

The frame here is Brooks's distinction (Brooks 1987)1. Some complexity is essential: the concepts a capability genuinely requires, irreducible for as long as you want that capability. The rest is accidental: extra cognitive cost from how the code happens to be written, removable without giving anything up. Refactoring targets the accidental part.

Sketch: capability on the x-axis, cognitive cost on the y-axis. A rising bold line is the essential-complexity floor; the shaded region above it is accidental complexity. A point high in that region marks where real code sits, and a downward arrow labeled "refactoring" drops it straight down toward the floor. Sketch: capability on the x-axis, cognitive cost on the y-axis. A rising bold line is the essential-complexity floor; the shaded region above it is accidental complexity. A point high in that region marks where real code sits, and a downward arrow labeled "refactoring" drops it straight down toward the floor.

Capability on the horizontal axis, cognitive cost on the vertical. The bold line is the essential-complexity floor; everything above it is accidental. Refactoring is the downward pull at fixed capability. This is a schematic: the axes carry no units and the floor cannot be located in practice, so only the direction of a move means anything, never a coordinate.

Real code sits above the floor because features arrive faster than cleanup, and each one piles on some accidental cost.

The move: hold capability, lower cost

Every refactoring holds capability fixed and lowers cost. Reading "capability" as features only is the common trap. Speed, safety, and verifiability are capabilities too, so a structure that exists to buy performance or a safety guarantee is essential to that capability. Deleting it because it looks like clutter is a capability loss dressed as a cleanup, not a refactor.

A refactor moves toward a shape once duplication and structure reveal it, rather than imposing an abstraction onto code that hasn't earned it. Collapsing two blocks that only resemble each other is the classic misfire: the wrong abstraction couples them under a concept the domain never had, and costs more than the duplication it replaced. That prescriptive reflex adds accidental complexity rather than removing it: it is design folklore, not refactoring.

What it catches

Accidental complexity accumulates in a few recurring forms:

  • Dead code — unreachable branches, unused functions, exports nothing imports. Pure accidental cost: removing it loses no capability, and the "is this still used?" load goes with it.
  • Divergent duplication — the same logic copied and drifted apart, so the reader has to hold every copy at once. The hardest case is duplication that emerged independently, where the shared concept was never named.
  • Unrecognizable structure — code that matches no idiom or convention the reader already holds, forcing re-derivation where recall would have done.
  • Unpredictable layout — like things shaped differently, so their location can't be guessed and every lookup becomes a search.

All of these forms block new work: a developer cannot add the next feature because the code around it is unreadable.

Refactoring does not touch what isn't accidental: missing requirements, or performance rooted in algorithmic choice rather than code shape. Those are essential-complexity questions a reshape cannot reach.

The discipline, and what actually stays fixed

Three practices separate refactoring from rewriting:

  1. Tests exist before the refactor starts. Without them, you are rewriting and behavior preservation rests on untested judgment. For legacy code with none, the first move is to get its current behavior under test, whatever that behavior is, before touching the code.
  2. Each step is small and reversible. A session is many tiny commits, each green: one change, one green run, revert if it isn't.
  3. Behavior is preserved by construction. Some moves are equivalence-preserving transforms: a consistent rename, or a codemod applied uniformly, produces equivalent code by mechanical rewrite regardless of who runs it. Tests are the net; the transform is the proof.

What stays fixed is behavior at the boundary, not the whole test suite. Behavior is defined where the piece meets the outside, so only tests written at that boundary survive an internal reshape. A test pinned to internal structure describes the structure, so any move that reshapes it breaks the test, and that breakage reads as a behavior change even though the boundary behavior is untouched. Such tests are scaffolding: expect them to die with the structure they described, and do not count their loss as a regression. The more internal the test, the smaller the move it permits: over-specified unit tests buy confidence today and charge it back as friction against the very cleanups they were meant to protect.

Tools

Refactoring tooling does two separate jobs: deciding where the accidental cost sits, and making the change without altering behavior. The first is semantic judgment; the mechanical tools only guarantee the change is safe, never that it was worth making.

Finding where the cost sits

  • AI code review. Claude's code-review skill, Cursor, and similar agents read the diff semantically and flag the accumulated-complexity, naming, and duplication that motivate a refactor.
  • Git-history hotspots. Files that change often and attract bug-fix commits are the empirical signal for where to spend the effort.

Making the move without changing behavior

The mechanical part of a down-move preserves behavior by construction; the work is in choosing the move, not performing it.

  • Codemods encode a mechanical move as a reusable, reviewable script that runs across a whole codebase: jscodeshift / ts-morph (JS/TS), comby (language-agnostic structural search and replace), rope (Python), and OpenRewrite (Java/Kotlin, recipe-based).
  • A formatter is not a refactoring, but running one consistently (gofmt, Prettier, Black, rustfmt) makes moves stable in the stable-sort sense: unchanged code keeps its place, so a reshape lands as only the behavior-relevant diff instead of a reformatting storm. That clean diff is what keeps a move small, reviewable, and its effect isolable.
  • A regression netapproval and snapshot tests pin current behavior so a reshape that changes it shows up; they catch change, not correctness.
  • A net is only worth trusting if it is strong enough to catch the behavior changes a bad refactor introduces, which is what measuring test-suite effectiveness establishes.

When to use, when not

Use:

  • Before adding a feature to a hard-to-change area: make the change easy first, then make the easy change.
  • When tests exist and the code is the bottleneck. Extract, rename, simplify; the boundary tests prove behavior is preserved.
  • As a routine PR-time activity. Small refactors compound; leave each file a little closer to its floor than you found it.

Don't:

  • Without tests. There is no boundary to hold the reshape against, so it is rewriting, not refactoring.
  • For green-field code where the tests don't exist yet. Build the shape iteratively; refactor when the shape stops fitting.
  • As a substitute for a real redesign. When the essential floor itself is wrong (the wrong concepts, the wrong architecture), no down-move reaches it; reshaping only locks in the wrong frame. See ADRs for the decision shape and budget a rewrite.
  • For its own sake. A refactor without a destination is re-arrangement; pick the move that unblocks the next thing.

Evidence

  • How programmers actually refactor. Programmers refactor far more than they self-report, and automated refactoring tools are under-used relative to their availability (Murphy-Hill et al. 2009)2.
  • A methodology caveat. Around a tenth of code changes in real history are refactorings invisible at the commit level (Negara et al. 2014)3.

The case for refactoring rests on maintainability: code that cannot be safely changed loses value over time. The empirical literature is largely descriptive (what programmers do, what tools support) rather than controlled outcome studies that show refactoring improves it. The capability-vs-cost sketch is a mental model, not a measured result.

The term, and other definitions

"Refactoring" was coined by William Opdyke and Ralph Johnson, who defined it as a behavior-preserving program transformation: a restructuring guarded by preconditions that guarantee observable behavior is unchanged (Opdyke 1992)4. The definition most developers use is Martin Fowler's: a change to the internal structure of software that makes it easier to understand and cheaper to modify without altering observable behavior (Fowler 1999)5.

Both define refactoring by what it may change. The definition used here defines it by purpose: reducing accidental complexity.

Classification

  • Quality dimensions: Maintainability.
  • Area: Maintenance of long-lived codebases, legacy code without tests, structural cleanup ahead of new features.

Referenced by

References


  1. Brooks, Frederick P. 1987. "No Silver Bullet: Essence and Accidents of Software Engineering." Computer 20 (4): 10–19. https://doi.org/10.1109/MC.1987.1663532

  2. Murphy-Hill, Emerson, Chris Parnin, and Andrew P. Black. 2009. "How We Refactor, and How We Know It." Proceedings of the 31st International Conference on Software Engineering (ICSE '09), 287–97. https://doi.org/10.1109/ICSE.2009.5070529

  3. Negara, Stas, Mihai Codoban, Danny Dig, and Ralph E. Johnson. 2014. "Mining Fine-Grained Code Changes to Detect Unknown Change Patterns." Proceedings of the 36th International Conference on Software Engineering (ICSE '14), 803–13. https://doi.org/10.1145/2568225.2568317

  4. Opdyke, William F. 1992. "Refactoring Object-Oriented Frameworks." PhD thesis, University of Illinois at Urbana-Champaign. https://www.laputan.org/pub/papers/opdyke-thesis.pdf

  5. Fowler, Martin. 1999. Refactoring: Improving the Design of Existing Code. Addison-Wesley Professional. https://archive.org/details/isbn_9780201485677