← Writing

Refactor in phases, not rewrites

Every engineer knows the feeling.

You open a tangled piece of code, see how badly the structure has drifted, and immediately want to delete it and rebuild it correctly. The rewrite feels clean. The old code feels compromised.

That instinct is understandable. It is also how a lot of rewrites die.

The reason is simple: a rewrite removes the safety net before the replacement has proved anything. The behavior you understand, however messy, disappears first. The new structure is still incomplete. Every small surprise now lands in production risk.

A safer way to improve design is slower and much less dramatic. Refactor in phases.

Use a hit-test function in a design tool as the example. Here is the kind of function that wants to be split:

function hitTest(
  scene: Scene,
  point: Vec2,
  mode: 'select' | 'hover' | 'tooltip'
): HitResult | null {
  if (mode === 'select') {
    // selection-specific logic
  }

  if (mode === 'hover') {
    // hover-specific logic
  }

  if (mode === 'tooltip') {
    // tooltip-specific logic
  }

  return null;
}

This function has more than one reason to change. Selection behavior, hover behavior, and tooltip behavior will drift apart. The mistake would be trying to replace it all in one shot.

Phase 1 is coexistence. Add the new structure next to the old structure.

function hitTestForSelection(
  scene: Scene,
  point: Vec2
): SelectionHit | null {
  // focused implementation
  return null;
}

function hitTestForHover(
  scene: Scene,
  point: Vec2
): HoverHit | null {
  // focused implementation
  return null;
}

// old function still exists
function hitTest(
  scene: Scene,
  point: Vec2,
  mode: 'select' | 'hover' | 'tooltip'
): HitResult | null {
  if (mode === 'select') {
    return hitTestForSelection(scene, point);
  }

  if (mode === 'hover') {
    return hitTestForHover(scene, point);
  }

  if (mode === 'tooltip') {
    // old path still in place
  }

  return null;
}

That stage feels less satisfying than a clean rewrite because the old and new paths coexist. Good. That is what makes it safe.

The old function still works. Existing callers still work. The new focused functions can get their own tests. You have not bet the whole system on the new structure yet.

Phase 2 is migration. Move one caller at a time.

// before
const result = hitTest(scene, point, 'select');

// after
const result = hitTestForSelection(scene, point);

That does not look like much. It is exactly enough.

Each migration is small. Each one can be code reviewed without heroic effort. Each one can be reverted without tearing the whole feature apart. This is where a refactor earns trust: not by being elegant on day one, but by being survivable on day ten.

As callers move over, the old function shrinks. Eventually it becomes a thin compatibility wrapper around the remaining old path. Then it becomes dead code.

Phase 3 is deletion.

function hitTestForSelection(
  scene: Scene,
  point: Vec2
): SelectionHit | null {
  // final implementation
  return null;
}

function hitTestForHover(
  scene: Scene,
  point: Vec2
): HoverHit | null {
  // final implementation
  return null;
}

function hitTestForTooltip(
  scene: Scene,
  point: Vec2
): TooltipHit | null {
  // final implementation
  return null;
}

At that point deletion is the easy part because the system has already proved the new structure in production-shaped use.

This phased approach sounds conservative because it is. That is the point.

It also clarifies something people often blur together: refactoring and rewriting are not the same thing.

A refactor changes structure while preserving behavior.

A rewrite changes structure and behavior at the same time and hopes that the outcome matches the old system where it needs to.

That is why refactors need a stronger safety discipline. If your tests all have to change with the code, you may not be refactoring anymore. You may be redesigning behavior under the cover of cleanup.

None of this means “never rewrite.” Sometimes a system is small enough, isolated enough, or disposable enough that a rewrite is the right call. But most of the time, the code that needs architectural improvement is also code carrying real business behavior, edge cases, and product history. Throwing that away all at once is usually the expensive path, not the clean one.

The boring version wins more often.

Introduce the new structure next to the old one. Migrate callers gradually. Delete only when the code is truly unused. Keep the safety net until the replacement has earned trust.

That is how you improve a system without betting the whole system on your confidence.

View on Bearblog