Refactoring Legacy Java Code: From A to Z
Working with legacy Java code is a challenge every developer faces. Old code often lacks tests, contains outdated patterns, and is difficult to change. However, completely rewriting the project is not always the best solution. In this article, we will break down a proven approach to refactoring legacy code: from analysis to implementing changes, with concrete examples in Java.
1. Diagnosis and Preparation: Before Touching the Code
The first and most important step is to understand what you are dealing with. Do not start refactoring by chaotically editing lines. Instead:
- Dependency Map: Visualize which classes depend on which. Use tools like IntelliJ IDEA Dependency Viewer or JDepend.
- Characterization Tests: Write tests that capture the current behavior of the code. This is your safety net.
- Identify Entry Points: Determine which methods are called most often and where the main changes occur.
Example of a simple characterization test for a method that needs refactoring:
import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.assertEquals;
public class LegacyCalculatorTest {
@Test public void testLegacyCalculate() { LegacyCalculator calc = new LegacyCalculator(); // Capture current behavior int result = calc.calculate(10, 5, "add"); assertEquals(15, result, "Current behavior: addition"); result = calc.calculate(10, 5, "sub"); assertEquals(5, result, "Current behavior: subtraction"); }}Such a test does not verify the correctness of the logic in an absolute sense — it simply records what the code does now. After refactoring, the test must pass, otherwise you have changed the behavior.
2. The "Surgical" Refactoring Strategy: Method by Method
Trying to refactor an entire class at once is a sure way to introduce bugs. Use the "Combat Archaeology" approach:
- Choose one method or a small group of related methods.
- Write characterization tests for them (if they don't exist).
- Apply one refactoring technique (e.g., Extract Method or Rename Variable).
- Run the tests. They should pass.
- Commit the changes with a clear description.
Example: refactoring a method with "magic numbers" and a long parameter list.
Original code (bad):
public class OrderProcessor { public double process(double amount, int type, double discount) { double result; if (type == 1) { result = amount * 1.2; // 20% markup if (discount > 0.1) { result = result - result * 0.05; // 5% discount } } else if (type == 2) { result = amount * 1.5; // 50% markup } else { result = amount; } return result; }}After refactoring (improved):
public class OrderProcessor { private static final double PREMIUM_MARKUP = 1.2; private static final double STANDARD_MARKUP = 1.5; private static final double LOYALTY_DISCOUNT = 0.05; private static final double DISCOUNT_THRESHOLD = 0.1;
public double process(OrderType type, double amount, double discount) { double basePrice = applyMarkup(amount, type); return applyLoyaltyDiscount(basePrice, discount); }
private double applyMarkup(double amount, OrderType type) { return switch (type) { case PREMIUM -> amount * PREMIUM_MARKUP; case STANDARD -> amount * STANDARD_MARKUP; case BASIC