All posts
Engineering

Refactoring incrementally: why big-bang rewrites fail and PRs succeed

Abstract incremental stacking refactoring blocks representing step-by-step codebase improvement

There is a conversation that happens in most engineering teams every 12 to 18 months. The codebase has accumulated enough cruft that features are getting slower to build. Someone raises it in a planning session. The team agrees: we need a refactor. And then comes the question that determines how the next six months go: do we tackle this incrementally as part of normal sprints, or do we take a quarter to rewrite the problem modules properly?

The rewrite argument is seductive. You get to fix everything at once. No half-measures. No living with the old architecture while the new one gets bolted on. It sounds more rigorous, more thorough. The incremental approach feels like painting a house one brushstroke at a time while people are still living in it.

The problem is that rewrites have a near-universal failure mode that the big-bang framing obscures. And building Pylon-Refactor forced us to confront this directly, because a tool that rewrites code in large batches would have the same failure mode at machine speed.

Why rewrites fail: the moving target problem

When you branch off the main codebase to do a rewrite, you immediately create two versions of reality. The rewrite branch starts accumulating work. Meanwhile, the main branch keeps moving: bug fixes, new features, hotfixes. By the time you're ready to merge the rewrite, you're not merging clean new code into a stable codebase. You're merging six weeks of drift into a codebase that has itself changed six weeks' worth.

Every day the rewrite branch exists is a day the merge becomes more expensive. Engineers on the rewrite team sometimes spend 30 to 40 percent of their time just staying current with main rather than making forward progress on the rewrite itself. We have talked with engineers at growing companies who estimated their last major rewrite consumed three times its original scope estimate, largely because of this re-integration cost that nobody budgeted for.

There is a second failure mode that is less discussed: knowledge drift. The engineers who designed the new architecture six weeks ago understood the codebase differently than the engineers who built it over the following weeks. When you reach the end of the rewrite and need to verify correctness, the people who can verify it are often not the same people who specified the requirements. Something always gets lost.

The incremental constraint is actually a forcing function

Incremental refactoring has a constraint that feels limiting but is actually valuable: every PR must be independently mergeable and must not regress existing tests. You cannot land "part one of the new architecture" if part one breaks the build. This forces you to decompose the refactor into steps that each leave the system in a valid state.

That constraint catches design problems early. If you cannot figure out how to modernize module A without breaking module B, that dependency is telling you something real about the architecture. A big-bang rewrite lets you paper over that dependency by changing both modules simultaneously. Incremental refactoring requires you to understand it.

This is the insight that shaped how we built Pylon-Refactor. The agent does not attempt to rewrite a whole service at once. It identifies the smallest coherent unit of change that can be safely reviewed, opens a PR for that unit, and waits. Only after that PR merges does it proceed to the next unit. The refactor plan is a queue of small steps, not a single large transformation.

How Pylon decides what a "unit" is

The repository context graph that underlies all Pylon jobs is especially important for incremental refactoring. When Pylon-Refactor plans a job, it uses the graph to identify which modules have the fewest downstream dependents. Those are the safest starting points: changing them affects the smallest number of other files, so the PR is easiest to review and has the smallest blast radius if something goes wrong.

Consider a hypothetical: a Node.js service where the team wants to migrate from CommonJS require() to ES module imports. A naive approach would be to change every file at once. But the graph shows that there are four utility modules imported by 40 other files. Those four modules are the highest-risk files to change. Pylon starts with the leaf modules: the ones that import from the utilities but are not themselves imported by many other modules. Each PR changes five to eight files. After ten PRs, the utilities are finally updated and the changeset that touches everything else is a one-liner find-and-replace with no behavior risk.

This ordering is not obvious when you look at the codebase as a flat list of files. It only becomes clear when you look at the graph structure. That is part of why automated tools have an edge over manual refactoring plans: the graph traversal is mechanical and exhaustive in a way that a human doing it mentally is not.

The code review surface is smaller per PR

A 50-file PR is not five times harder to review than a 10-file PR. It is roughly ten times harder, because the reviewer has to hold the entire changeset in their head to verify correctness. Small PRs are not just easier to merge: they are actually more likely to be reviewed carefully. An engineer who sees a 200-line PR will read it closely. An engineer who sees a 2,000-line PR will skim it and hope the tests catch anything important.

When Pylon opens a refactoring PR, the description explicitly states the scope: which module was changed, what pattern was applied, what the before/after behavior is, which tests exercise the changed paths. The reviewer's job is to verify that the description matches the diff, not to reconstruct the intent from the diff alone. That is a fundamentally different cognitive task and it takes far less time.

What incremental refactoring still cannot do

We want to be clear about the limits here. Incremental refactoring works well for pattern-level changes: updating import syntax, migrating from one library to another with an equivalent API, standardizing error handling, removing dead code. It works less well when the target architecture is fundamentally incompatible with the current one, where every intermediate state is invalid.

If you need to replace a synchronous request-response architecture with an event-sourced one, there is no clean series of small PRs that gets you there without a period where both models exist simultaneously. That kind of change requires careful design of a transition layer, and that transition layer itself becomes technical debt during the migration period. Incremental refactoring does not make hard problems disappear. It makes the common case of accumulated technical debt more tractable.

The big-bang rewrite is not always wrong. For a module with zero tests, no downstream dependents, and a clean API boundary, a full rewrite behind a stable interface can be the right call. We just find that this describes a much smaller fraction of real refactoring work than teams tend to assume when they are planning.

The default should be incremental. The rewrite should need justification, not the other way around.