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.
A bill in one method
statement() works out units, prices them by tariff, adds a daily charge and formats the line.
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.
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"));
}
}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.
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 |
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.
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);
}
}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 |
|
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:
- Make sure the tests pass.
- Make one small, named change.
- Run the tests. If they fail, undo the change rather than debug it.
- Commit, and repeat.
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
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.