Low-level designCore9 min

Refactoring in small steps

Refactoring changes the structure of code without changing what it does. Done in small, named steps with a check run after each one, it turns a design you'd hesitate to touch into one that's easy to change, without ever leaving the program broken.

Watch it evolve1 / 10
The problem

A bill in one method

statement() works out units, prices them by tariff, adds a daily charge and formats the line.

80
Start
Stage 1
Stage 2
Stage 3
Stage 4
Final

1 · The situation

An electricity bill in one method

A small energy co-op bills two kinds of customer. Homes pay 20 cents a kWh for the first 100 and 30 cents after that, plus 40 cents a day; businesses pay a flat 25 cents a kWh plus 90 cents a day. Billing.statement() does it all in one method: it works out the units from two meter readings, prices them by tariff, adds the daily charge, and formats the line.

At the end, main runs a small check: a home that used exactly 100 kWh should pay $20.00 for energy. It's the kind of edge case that breaks when the tier rule is touched. Python writes start_reading and the helpers with underscores; the other names are the same in all four languages.

The first versionJava
enum Tariff { HOME, BUSINESS }

class Billing {
    String statement(String customer, Tariff tariff, int startReading, int endReading, int days) {
        int units = endReading - startReading;
        int energy;
        switch (tariff) {
            case HOME: energy = units <= 100 ? units * 20 : 100 * 20 + (units - 100) * 30; break;
            default: energy = units * 25; break;
        }
        int standing;
        switch (tariff) {
            case HOME: standing = days * 40; break;
            default: standing = days * 90; break;
        }
        int total = energy + standing;
        return customer + ": " + units + " kWh, energy " + money(energy)
                + ", standing " + money(standing) + ", total " + money(total);
    }

    private static String money(int cents) {
        return String.format("$%d.%02d", cents / 100, cents % 100);
    }
}

public class Main {
    public static void main(String[] args) {
        Billing billing = new Billing();
        System.out.println(billing.statement("Rosa", Tariff.HOME, 1200, 1340, 30));
        System.out.println(billing.statement("Bakery", Tariff.BUSINESS, 5000, 5300, 30));
        String test = billing.statement("Test", Tariff.HOME, 0, 100, 30);
        String expected = "Test: 100 kWh, energy $20.00, standing $12.00, total $32.00";
        System.out.println("check 100 kWh at home: " + (test.equals(expected) ? "pass" : "FAIL"));
    }
}
«enum»TariffHOMEBUSINESSBilling~ statement(String, Tariff, int, int, int): String− money(int): String

The class diagram of this version, drawn from its code: one class, one method, one enum.

Rosa: 140 kWh, energy $32.00, standing $12.00, total $44.00
Bakery: 300 kWh, energy $75.00, standing $27.00, total $102.00
check 100 kWh at home: pass

2 · The change

A new tariff, and a method you'd rather not touch

The co-op plans a night tariff, cheaper after ten. Adding it to this method means a case in each switch, buried among the meter arithmetic and the formatting, with the tier rule a line away. It can be done; it's just easy to get wrong.

Two switches inside one methodJava
class Billing {
    String statement(String customer, Tariff tariff, int startReading, int endReading, int days) {
        int units = endReading - startReading;
        int energy;
        switch (tariff) {
            case HOME: energy = units <= 100 ? units * 20 : 100 * 20 + (units - 100) * 30; break;
            default: energy = units * 25; break;
        }
        int standing;
        switch (tariff) {
            case HOME: standing = days * 40; break;
            default: standing = days * 90; break;
        }
        int total = energy + standing;
        return customer + ": " + units + " kWh, energy " + money(energy)
                + ", standing " + money(standing) + ", total " + money(total);
    }

    private static String money(int cents) {
        return String.format("$%d.%02d", cents / 100, cents % 100);
    }
}

The checks find nothing here. They look for one enum switched on in several methods, and both switches sit in the same method, so the smell is hidden in its length.

There's a better order than editing in place: first change the structure so the new tariff is easy to add, without changing any behavior; then add it. The first part is refactoring, and it goes in small steps:

  • Each step has a name and a known way to do it.
  • Each step keeps the program working: the output, including the check, stays the same.
  • Each step is small enough that if the check fails, the cause is the last few lines you touched.

Job inside statement()

Where it should end up

Units from two readings

With the readings

Pricing energy by tariff

With each tariff

The daily charge by tariff

With each tariff

Formatting the line

In statement(), which is what it's for

3 · The fix

Four named steps, the check passing after each

Each step below is one refactoring from the standard catalog. Run the program after each: all five versions print exactly the same three lines.

Pull each switch into its own method, energyCents() and standingCents(). statement() shrinks to arithmetic and formatting. Now the checks see the same enum switched on in two methods and flag it: extraction made the smell visible.

Java
class Billing {
    String statement(String customer, Tariff tariff, int startReading, int endReading, int days) {
        int units = endReading - startReading;
        int energy = energyCents(tariff, units);
        int standing = standingCents(tariff, days);
        return customer + ": " + units + " kWh, energy " + money(energy)
                + ", standing " + money(standing) + ", total " + money(energy + standing);
    }

    private int energyCents(Tariff tariff, int units) {
        switch (tariff) {
            case HOME: return units <= 100 ? units * 20 : 100 * 20 + (units - 100) * 30;
            default: return units * 25;
        }
    }

    private int standingCents(Tariff tariff, int days) {
        switch (tariff) {
            case HOME: return days * 40;
            default: return days * 90;
        }
    }

    private static String money(int cents) {
        return String.format("$%d.%02d", cents / 100, cents % 100);
    }
}
«enum»TariffHOMEBUSINESSBilling~ statement(String, Tariff, int, int, int): String− energyCents(Tariff, int): int− standingCents(Tariff, int): int− money(int): String
Rosa: 140 kWh, energy $32.00, standing $12.00, total $44.00
Bakery: 300 kWh, energy $75.00, standing $27.00, total $102.00
check 100 kWh at home: pass

The output of the last step: the same three lines the first version printed.

Version

The refactoring

What the checks report

First

None

Nothing: both switches hide in one method

Extracted

Extract method

Tariff switched on in 2 places

Parameter object

Introduce parameter object

The same

Moved

Move method

The same

Polymorphic

Replace conditional with polymorphism

Nothing to flag

Now the night tariff is one new class with two short methods, and statement() doesn't change. The refactoring didn't add the feature; it made adding it a small, safe edit.

4 · The technique

Change the structure, never the behavior

Refactoring is restructuring code without changing its external behavior. It's done as a series of small, standard transformations, each of which leaves the program working. The loop is always the same:

  1. Make sure the tests pass.
  2. Make one small, named change.
  3. Run the tests. If they fail, undo the change rather than debug it.
  4. Commit, and repeat.
«interface»Tariff+ energyCents(int): int+ standingCents(int): intHomeTariff+ energyCents(int): int+ standingCents(int): intBusinessTariff+ energyCents(int): int+ standingCents(int): int«record»MeterReading− start: int− end: int~ units(): intBilling~ statement(String, Tariff, MeterReading, int): S…− money(int): String

After the four steps: the readings know their units, each tariff knows its prices, and Billing formats the statement.

Refactoring

What it does

The smell it answers

Extract method

Turns a block of code into a named method

Long method

Inline method

Replaces a call with the method's body

A method that adds nothing

Rename

Gives a variable, method or class a better name

A name that misleads

Move method

Puts a method in the class whose data it uses

Feature envy

Extract class

Splits off a group of fields and methods

A class with two jobs

Introduce parameter object

Replaces values that travel together with one type

Data clumps

Replace conditional with polymorphism

Turns a switch on type into a method each type overrides

Switch on type

5 · When to hold off

No tests, no refactoring; and never mixed with features

Refactoring without a way to check behavior is just editing. Hold off, or prepare first, when:

  • There are no tests. Write a few that pin down what the code does today, wrong or not, before changing its shape. They're often called characterization tests.
  • The code is about to be deleted. Tidying what won't survive the month is wasted work.
  • You're in the middle of a feature. Finish it, or set it aside, then refactor. A commit that both restructures and changes behavior can't be reviewed or reverted cleanly.

Approach

Risk

When it fits

Small refactoring steps, tests after each

Low: a failure points to the last step

Almost always

One big restructuring in a branch

High: many changes fail together

Rarely; split it into steps instead

A rewrite from scratch

Highest: old behavior is lost unless every rule is rediscovered

When the old code can't be tested or understood at all

6 · Getting it right

One hat at a time

  • Wear one hat at a time. Either you're refactoring, and behavior must not change, or you're adding a feature, and structure stays put. Switch hats often, but never wear both.
  • Take steps you can name. "Extract method" has a known recipe; "clean this up" doesn't.
  • Let the tools do it. Automated rename, extract and move in an editor are faster and safer than editing by hand.
  • Keep the check fast. You'll run it after every step; a suite that takes minutes discourages the habit.
  • Stop when the change is easy. The goal was room for the night tariff, not a perfect design.

How much the tools can do depends on the language:

Language

Automated refactoring

What carries the rest

Java

Mature in IDEs: rename, extract, move, change signature

The compiler catches most slips

C++

Rename and extract in most editors; macros and templates limit them

The compiler, plus tests

JavaScript

Rename and extract through the editor's language service

Tests, since mistakes show only at run time

Python

Rename and extract in IDEs, less certain because of dynamic typing

Tests, and a type checker if the code has hints

7 · Look-alikes

Not every code change is a refactoring

Several kinds of change get called refactoring. Only one of them promises the behavior stays the same.

Change

Behavior

Structure

Refactoring

Unchanged

Improved, in small steps

Rewrite

Meant to be the same, rarely is

Replaced

Bug fix

Changed, on purpose

Usually unchanged

Optimization

Unchanged, but faster

Often made harder to read

Reformatting

Unchanged

Unchanged; only the layout moves

The steps here follow Code smells: each smell named there has a refactoring that answers it. And the last step produced the shape from Open/closed and Strategy. Patterns are often where refactoring ends up, rather than where design starts.

8 · In practice

The vending machine, one step at a time

The vending-machine practice draft switches on its Status in three methods. The fix is the same move as the last step here, replace conditional with polymorphism, and it goes best in steps: extract each case into a method, then move the methods into a class per state.

The checks rerun as you type, so they work like the check in this page's main: change one thing, look, change the next. Fix the vending machine in small steps.

Check yourself

4 questions

1. After the extract-method step, the checks report a switch on Tariff in two places. Did the step make the code worse?
2. A refactoring step makes the check fail. What should you do?
3. Which of these is a refactoring?
4. Legacy code has no tests and needs restructuring. What comes first?

Takeaways

Remember this

  • Refactoring changes structure without changing behavior, in small, named steps.
  • Run the tests after every step; if one fails, undo the step instead of debugging it.
  • Make the change easy first, then make the easy change.
  • Each common smell has a refactoring that answers it.
  • Never mix refactoring and behavior changes in one step or one commit.