EngineeringCode Quality

Refactoring Techniques

Safe, incremental approaches to improving existing code without breaking behaviour.

Overview

Refactoring is the disciplined process of restructuring existing code — changing its internal structure — without changing its observable behavior. The goal is to make code easier to understand, easier to change, and cheaper to extend, while leaving what it does for users completely intact.

The key word is disciplined. Refactoring is not "rewriting things that look ugly." It is a sequence of small, verifiable, behavior-preserving transformations applied with intent. Each step is small enough that if something breaks, you know exactly where to look.

This page covers how to refactor safely. For identifying what to refactor — the patterns and smells that signal code needs attention — see Reducing Code Smells & Anti-Patterns.


Why It Matters

Code that is hard to change accumulates bugs. When a function is 300 lines with six levels of nesting and implicit global state, developers work around it rather than through it. Workarounds create inconsistency. Inconsistency creates bugs.

Refactoring is cheaper than rewrites. Full rewrites carry enormous risk: you lose the edge-case handling that accreted over years, you lose the history of why things are the way they are, and you deliver nothing to users during the rewrite. Incremental refactoring delivers value continuously and degrades risk.

Technical debt compounds. A messy module is slow to change. A slow-to-change module gets fewer improvements. Fewer improvements means worse abstractions over time. The problem grows nonlinearly. Refactoring is the mechanism for paying down debt before the interest becomes prohibitive.

It is a core engineering skill. The ability to work with existing code — to improve it safely, without a rewrite, without breaking it — is one of the most valuable skills on a software team. It is also one of the most underrated.


Standards & Best Practices

Never refactor without tests

If the code you're about to refactor isn't covered by tests, write the tests first. Tests are your safety net: they tell you immediately if a refactoring changed observable behavior. Refactoring untested code is guesswork.

If the code is too tangled to write unit tests for, write characterization tests: broad tests that capture the current behavior (even if that behavior is wrong) so you know what you're preserving. Then refactor toward testability.

One concern per commit

A commit should either refactor or change behavior — not both. Mixing a refactoring with a feature addition makes the diff impossible to review. It also makes reverting much harder. Keep refactors in their own commits with a clear message: refactor(auth): extract token validation into separate function.

Make it work, then make it right

Refactoring is for code that already works. Do not refactor broken code; fix it first. Do not add features during a refactor; finish the refactor first. These constraints keep the changes reviewable and the risk contained.

Small steps over large leaps

Each refactoring step should be small enough that you can verify it in under a minute. Run tests after every meaningful change. If tests fail, you know exactly which step broke something — not "somewhere in the last 200 lines I changed."

Agree before refactoring shared modules

A refactoring that touches a module owned by multiple teams requires coordination. The change may be correct, but if other teams don't know about it, they'll open PRs that conflict, or they'll be surprised by interface changes. Communicate before refactoring shared surfaces.


How to Implement

The following are the most commonly applicable refactoring techniques, organized from lower to higher risk.

Rename

What: Rename a variable, function, parameter, class, or module to better reflect its purpose.

When to use: The name is misleading, abbreviated to the point of obscurity, or no longer matches what the thing does.

How: Use your IDE's rename refactoring — it finds and updates all call sites automatically. Never do a manual find-and-replace across a codebase for identifiers.

// Before
function calc(u: User, d: Date): number { ... }

// After
function calculateDaysUntilExpiry(user: User, referenceDate: Date): number { ... }

Extract Function

What: Move a block of code into its own named function.

When to use: A function is too long, a code block has a clear purpose that can be named, or the same logic appears in multiple places.

How: Identify the block. Determine what inputs it needs (parameters) and what it produces (return value). Move the block into a new function; call it from the original location. Run tests.

// Before
function processOrder(order: Order) {
  // validate
  if (!order.items.length) throw new Error('Empty order');
  if (!order.customerId) throw new Error('No customer');

  // calculate total
  const total = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  // persist...
}

// After
function validateOrder(order: Order): void {
  if (!order.items.length) throw new Error('Empty order');
  if (!order.customerId) throw new Error('No customer');
}

function calculateOrderTotal(order: Order): number {
  return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

function processOrder(order: Order) {
  validateOrder(order);
  const total = calculateOrderTotal(order);
  // persist...
}

Extract Variable (Introduce Explaining Variable)

What: Assign the result of a complex expression to a well-named variable.

When to use: An expression is complex enough that its purpose isn't obvious from reading it.

// Before
if (user.role === "admin" && user.createdAt < Date.now() - 30 * 24 * 60 * 60 * 1000) { ... }

// After
const isAdmin = user.role === "admin";
const isEstablishedAccount = user.createdAt < Date.now() - 30 * 24 * 60 * 60 * 1000;
if (isAdmin && isEstablishedAccount) { ... }

Introduce Parameter Object

What: Replace a long list of related parameters with a single object.

When to use: A function takes 4+ parameters that logically belong together, or the same parameter group appears across multiple functions.

// Before
function createReport(startDate: Date, endDate: Date, region: string, currency: string) { ... }

// After
interface ReportOptions {
  startDate: Date;
  endDate: Date;
  region: string;
  currency: string;
}
function createReport(options: ReportOptions) { ... }

Replace Conditional with Polymorphism

What: Replace a type-checking conditional (if type === X ... else if type === Y) with polymorphic dispatch.

When to use: The same conditional appears in multiple places, or adding a new "type" requires changing multiple functions.

// Before
function renderNotification(notification: Notification) {
  if (notification.type === 'email') return renderEmail(notification);
  if (notification.type === 'sms') return renderSms(notification);
  if (notification.type === 'push') return renderPush(notification);
}

// After — each type implements a render() method
function renderNotification(notification: Notification) {
  return notification.render();
}

Move Function / Move Field

What: Relocate a function or field to the class or module that it logically belongs to.

When to use: A function uses data from another class more than from its own class, or a field is read/written by only one external consumer.

This is often the right refactoring when you see "feature envy" — a function that is obsessed with data it doesn't own.

Inline Function / Inline Variable

What: The inverse of Extract. Replace a function call with its body, or a variable with its value.

When to use: The function's body is as readable as its name, the function is only called once and adds no clarity, or an intermediate variable adds noise rather than explanation.

Apply inline with the same care as extract — small steps, tests after each.

Strangler Fig Pattern (for large-scale refactors)

What: Gradually replace a legacy system by routing new behavior through a new implementation alongside the old one, then incrementally migrating traffic until the old code can be deleted.

When to use: A module or service is too large to refactor in one pass. You need to ship continuously while the refactor proceeds over weeks or months.

How:

  1. Create the new implementation alongside the old one.
  2. Route a small percentage of calls (or a specific use case) to the new path.
  3. Validate behavior parity.
  4. Incrementally expand routing to the new path.
  5. Delete the old implementation once it receives no traffic.

The name comes from the strangler fig tree, which grows around a host tree until the host is no longer needed.


Tools & Templates

IDE support

Most refactoring techniques are best executed with IDE tooling, not manual text editing:

IDEBuilt-in refactoring support
VS CodeRename symbol (F2), Extract to function/constant (right-click → Refactor)
JetBrains (WebStorm, IntelliJ)Comprehensive refactoring menu (Ctrl+Alt+Shift+T)
Vim/NeovimVia LSP plugins (typescript-language-server, etc.)

IDE refactors are safe for renames and extractions because they use the language server to find all references — they don't miss callsites the way a find-and-replace would.

Tracking refactoring work

Large refactors should be tracked as explicit work items, not as incidental changes bundled into feature PRs. Create a ticket, break it into small PRs (ideally under 300 lines each), and make progress visible.

A useful commit convention for refactoring PRs:

refactor(module): <what changed structurally>

No behavior change. Preparatory for <next feature or cleanup>.

Common Pitfalls

Refactoring without a failing test as a guide. If there are no tests, you don't know if you broke something. Write characterization tests before touching anything, even if they just assert the current (possibly incorrect) output.

Mixing refactoring with feature work. The temptation is real: you're in a file anyway, so you clean it up while adding the feature. This makes the PR nearly impossible to review — the reviewer can't tell what is a behavior change and what is a cleanup. Keep them separate.

The "while I'm in here" spiral. You extract one function, notice another that should be extracted, then notice the module structure is off, then realize the whole thing should be reorganized. Three hours later you have a 1,200-line diff and nothing is merged. Timebox refactoring. Do one thing, merge it, move on.

Refactoring toward a style preference rather than a measurable improvement. "I prefer classes over functions" is not a refactoring rationale. "This function is 200 lines and has 9 test cases that are hard to write because of how it mixes concerns" is. Refactoring has a cost; the benefit should be articulable.

Not communicating large refactors to the team. If you're moving a widely-used utility function, changing a module's public API, or restructuring a shared data model, other engineers with in-flight PRs will hit merge conflicts. Communicate the scope and timing before you start.